├── .changeset ├── README.md ├── config.json ├── orange-dancers-whisper.md └── rich-adults-train.md ├── .env.example ├── .eslintignore ├── .eslintrc.json ├── .github └── workflows │ ├── cypress.yml │ ├── cypress_integration.yml │ ├── e2e.yml │ ├── eslint.yml │ ├── jest.yml │ └── rubocop.yml ├── .gitignore ├── .husky └── pre-commit ├── .lintstagedrc ├── .rubocop.yml ├── .tool-versions ├── Gemfile ├── Gemfile.lock ├── LICENSE ├── README.md ├── babel.config.json ├── docs ├── CONTRIBUTING.md └── RELEASING.md ├── e2e ├── app.rb ├── app │ ├── base.rb │ ├── next_js.rb │ ├── next_ts.rb │ ├── redwood_js.rb │ ├── redwood_ts.rb │ ├── remix_js.rb │ ├── remix_ts.rb │ ├── standalone_js.rb │ ├── standalone_ts.rb │ └── turbo.rb ├── cli.rb ├── commands │ ├── run.rb │ ├── run_all.rb │ ├── setup.rb │ └── update_snapshot.rb ├── config.rb ├── helpers │ ├── opts_parser.rb │ ├── system_utils.rb │ └── test_runner.rb ├── mailing.rb └── mailing_tests │ ├── cypress.config.ts │ ├── cypress │ ├── e2e │ │ └── index.cy.js │ └── support │ │ ├── commands.js │ │ └── e2e.js │ └── jest │ ├── __snapshots__ │ └── exportPreviews.test.js.snap │ ├── babel.config.json │ ├── exportPreviews.test.js │ ├── jest.config.json │ ├── server.test.js │ └── util │ └── execCli.js ├── jest.config.ts ├── jest.integration.config.ts ├── jsdom.env.ts ├── package.json ├── packages ├── cli │ ├── .gitignore │ ├── .npmignore │ ├── CHANGELOG.md │ ├── README.md │ ├── bin.js │ ├── cypress.config.ts │ ├── cypress │ │ ├── e2e │ │ │ ├── __integration__ │ │ │ │ ├── audience.cy.integration.ts │ │ │ │ ├── login.cy.integration.ts │ │ │ │ ├── signupAndAuthenticate.cy.integration.ts │ │ │ │ └── unsubscribe.cy.integration.ts │ │ │ └── index.cy.js │ │ ├── support │ │ │ ├── commands.ts │ │ │ └── e2e.ts │ │ └── tsconfig.json │ ├── cypressIntegration.config.ts │ ├── next-env.d.ts │ ├── next.config.js │ ├── package.json │ ├── postcss.config.js │ ├── prisma │ │ ├── __mocks__ │ │ │ └── index.ts │ │ ├── index.ts │ │ ├── migrations │ │ │ ├── 20221013235437_add_organization_api_keyand_user │ │ │ │ └── migration.sql │ │ │ ├── 20221019231924_create_lists │ │ │ │ └── migration.sql │ │ │ ├── 20221020043430_create_members │ │ │ │ └── migration.sql │ │ │ ├── 20221020224222_analytics_schema │ │ │ │ └── migration.sql │ │ │ ├── 20221021232602_iterate_members │ │ │ │ └── migration.sql │ │ │ ├── 20221028184445_add_is_default_to_lists │ │ │ │ └── migration.sql │ │ │ ├── 20221104153624_member_and_list_timestamps │ │ │ │ └── migration.sql │ │ │ ├── 20221110210035_add_display_name │ │ │ │ └── migration.sql │ │ │ └── migration_lock.toml │ │ └── schema.prisma │ ├── public │ │ ├── favicon-16x16.png │ │ ├── favicon-32x32.png │ │ └── favicon.ico │ ├── src │ │ ├── __mocks__ │ │ │ ├── emails-js │ │ │ │ ├── AccountCreated.jsx │ │ │ │ ├── NewSignIn.jsx │ │ │ │ ├── Reservation.jsx │ │ │ │ ├── ResetPassword.jsx │ │ │ │ ├── components │ │ │ │ │ ├── BaseLayout.jsx │ │ │ │ │ ├── Button.jsx │ │ │ │ │ ├── Divider.jsx │ │ │ │ │ ├── Footer.jsx │ │ │ │ │ ├── Header.jsx │ │ │ │ │ ├── Heading.jsx │ │ │ │ │ ├── List.jsx │ │ │ │ │ └── Text.jsx │ │ │ │ ├── index.js │ │ │ │ ├── previews │ │ │ │ │ ├── AccountCreated.jsx │ │ │ │ │ ├── NewSignIn.jsx │ │ │ │ │ ├── Reservation.jsx │ │ │ │ │ └── ResetPassword.jsx │ │ │ │ └── theme.js │ │ │ ├── emails │ │ │ │ ├── AccountCreated.tsx │ │ │ │ ├── NewSignIn.tsx │ │ │ │ ├── Reservation.tsx │ │ │ │ ├── ResetPassword.tsx │ │ │ │ ├── components │ │ │ │ │ ├── BaseLayout.tsx │ │ │ │ │ ├── Button.tsx │ │ │ │ │ ├── Divider.tsx │ │ │ │ │ ├── Footer.tsx │ │ │ │ │ ├── Header.tsx │ │ │ │ │ ├── Heading.tsx │ │ │ │ │ ├── List.tsx │ │ │ │ │ └── Text.tsx │ │ │ │ ├── index.ts │ │ │ │ ├── previews │ │ │ │ │ ├── AccountCreated.tsx │ │ │ │ │ ├── NewSignIn.tsx │ │ │ │ │ ├── Reservation.tsx │ │ │ │ │ └── ResetPassword.tsx │ │ │ │ └── theme.ts │ │ │ └── moduleManifest.ts │ │ ├── commands │ │ │ ├── __test__ │ │ │ │ ├── __snapshots__ │ │ │ │ │ └── exportPreviews.test.ts.snap │ │ │ │ ├── execCli.ts │ │ │ │ ├── exportPreviews.test.ts │ │ │ │ ├── init.test.ts │ │ │ │ └── server.test.ts │ │ │ ├── exportPreviews.ts │ │ │ ├── init.ts │ │ │ ├── preview │ │ │ │ ├── preview.ts │ │ │ │ └── server │ │ │ │ │ ├── __integration__ │ │ │ │ │ └── livereload.test.ts │ │ │ │ │ ├── __test__ │ │ │ │ │ ├── setup.test.ts │ │ │ │ │ └── start.test.ts │ │ │ │ │ ├── livereload.ts │ │ │ │ │ ├── setup.ts │ │ │ │ │ ├── start.ts │ │ │ │ │ └── templates │ │ │ │ │ ├── feModuleManifest.template.ejs │ │ │ │ │ └── moduleManifest.template.ejs │ │ │ ├── server.ts │ │ │ └── util │ │ │ │ ├── __test__ │ │ │ │ ├── getNodeModulesDirsFrom.test.ts │ │ │ │ └── lintEmailsDirectory.test.ts │ │ │ │ ├── getNodeModulesDirsFrom.ts │ │ │ │ ├── lintEmailsDirectory.ts │ │ │ │ └── registerRequireHooks.ts │ │ ├── components │ │ │ ├── CircleLoader.tsx │ │ │ ├── FormError.tsx │ │ │ ├── FormSuccess.tsx │ │ │ ├── HamburgerContext.tsx │ │ │ ├── Header.tsx │ │ │ ├── HotIFrame.tsx │ │ │ ├── HtmlLint.tsx │ │ │ ├── IndexPane │ │ │ │ ├── ClientView.tsx │ │ │ │ ├── CompactView.tsx │ │ │ │ ├── IndexPane.tsx │ │ │ │ ├── __test__ │ │ │ │ │ └── IndexPane.test.tsx │ │ │ │ └── hooks │ │ │ │ │ ├── __test__ │ │ │ │ │ ├── __snapshots__ │ │ │ │ │ │ └── usePreviewTree.test.ts.snap │ │ │ │ │ └── usePreviewTree.test.ts │ │ │ │ │ └── usePreviewTree.ts │ │ │ ├── Intercept.tsx │ │ │ ├── MjmlErrors.tsx │ │ │ ├── MobileHeader.tsx │ │ │ ├── NavBar │ │ │ │ ├── NavBar.tsx │ │ │ │ ├── NavBarButton.tsx │ │ │ │ └── __test__ │ │ │ │ │ └── NavBar.test.tsx │ │ │ ├── PreviewSender.tsx │ │ │ ├── PreviewViewer.tsx │ │ │ ├── Tooltip.tsx │ │ │ ├── Watermark.tsx │ │ │ ├── __test__ │ │ │ │ ├── HTMLLint.test.tsx │ │ │ │ └── Intercept.test.tsx │ │ │ ├── hooks │ │ │ │ ├── useLiveReload.tsx │ │ │ │ ├── usePreviewHotkeys.tsx │ │ │ │ └── usePreviewPath.tsx │ │ │ ├── icons │ │ │ │ ├── IconAnalytics.tsx │ │ │ │ ├── IconAudience.tsx │ │ │ │ ├── IconClose.tsx │ │ │ │ ├── IconCode.tsx │ │ │ │ ├── IconDesktop.tsx │ │ │ │ ├── IconEye.tsx │ │ │ │ ├── IconGear.tsx │ │ │ │ ├── IconHome.tsx │ │ │ │ ├── IconMobile.tsx │ │ │ │ ├── IconQuestion.tsx │ │ │ │ ├── IconSend.tsx │ │ │ │ ├── IconWarning.tsx │ │ │ │ ├── LogoMark.tsx │ │ │ │ ├── LogoMarkSmall.tsx │ │ │ │ └── icons.d.ts │ │ │ └── ui │ │ │ │ ├── Button.tsx │ │ │ │ ├── Input.tsx │ │ │ │ ├── OutlineButton.tsx │ │ │ │ ├── PaginationControl.tsx │ │ │ │ └── Table.tsx │ │ ├── const │ │ │ └── mailingVersion.ts │ │ ├── custom-styled-jsx.d.ts │ │ ├── custom.d.ts │ │ ├── dev.js │ │ ├── emails-js │ │ │ ├── Welcome.jsx │ │ │ ├── components │ │ │ │ ├── BaseLayout.jsx │ │ │ │ ├── Button.jsx │ │ │ │ ├── Footer.jsx │ │ │ │ ├── Header.jsx │ │ │ │ ├── Heading.jsx │ │ │ │ ├── Link.jsx │ │ │ │ └── Text.jsx │ │ │ ├── index.js │ │ │ ├── previews │ │ │ │ └── Welcome.jsx │ │ │ └── theme.js │ │ ├── emails │ │ │ ├── Welcome.tsx │ │ │ ├── components │ │ │ │ ├── BaseLayout.tsx │ │ │ │ ├── Button.tsx │ │ │ │ ├── Footer.tsx │ │ │ │ ├── Header.tsx │ │ │ │ ├── Heading.tsx │ │ │ │ ├── Link.tsx │ │ │ │ └── Text.tsx │ │ │ ├── index.ts │ │ │ ├── previews │ │ │ │ └── Welcome.tsx │ │ │ └── theme.ts │ │ ├── feManifest.ts │ │ ├── index.ts │ │ ├── mjml-browser.d.ts │ │ ├── moduleManifest.ts │ │ ├── next-env.d.ts │ │ ├── pages │ │ │ ├── _app.tsx │ │ │ ├── _document.tsx │ │ │ ├── analytics.tsx │ │ │ ├── api │ │ │ │ ├── __integration__ │ │ │ │ │ ├── logout.test.ts │ │ │ │ │ ├── render.test.ts │ │ │ │ │ ├── sendMail.test.ts │ │ │ │ │ ├── users.test.ts │ │ │ │ │ └── util │ │ │ │ │ │ ├── apiKeys.ts │ │ │ │ │ │ ├── assertIntegrationTestEnv.ts │ │ │ │ │ │ ├── createOrganizationDefaultListAndApiKey.ts │ │ │ │ │ │ ├── createUser.ts │ │ │ │ │ │ ├── hooks │ │ │ │ │ │ ├── click.ts │ │ │ │ │ │ └── open.ts │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ ├── listMember.ts │ │ │ │ │ │ ├── lists.ts │ │ │ │ │ │ ├── login.ts │ │ │ │ │ │ ├── logout.ts │ │ │ │ │ │ ├── messages │ │ │ │ │ │ └── create.ts │ │ │ │ │ │ ├── render.ts │ │ │ │ │ │ ├── sendMail.ts │ │ │ │ │ │ ├── truncateCliTables.ts │ │ │ │ │ │ └── unsubscribe.ts │ │ │ │ ├── __test__ │ │ │ │ │ ├── render.test.ts │ │ │ │ │ ├── sendMail.test.ts │ │ │ │ │ ├── sendMailMock.test.ts │ │ │ │ │ └── session.test.ts │ │ │ │ ├── apiKeys │ │ │ │ │ ├── __integration__ │ │ │ │ │ │ └── apiKeys.test.ts │ │ │ │ │ └── index.ts │ │ │ │ ├── hooks │ │ │ │ │ ├── __integration__ │ │ │ │ │ │ ├── click.test.ts │ │ │ │ │ │ └── open.test.ts │ │ │ │ │ ├── __test__ │ │ │ │ │ │ ├── click.test.ts │ │ │ │ │ │ └── open.test.ts │ │ │ │ │ ├── click.ts │ │ │ │ │ └── open.ts │ │ │ │ ├── lists │ │ │ │ │ ├── [id] │ │ │ │ │ │ ├── __integration__ │ │ │ │ │ │ │ ├── members.test.ts │ │ │ │ │ │ │ └── subscribe.test.ts │ │ │ │ │ │ ├── members.ts │ │ │ │ │ │ ├── members │ │ │ │ │ │ │ ├── [memberId].ts │ │ │ │ │ │ │ └── __integration__ │ │ │ │ │ │ │ │ └── [memberId].test.ts │ │ │ │ │ │ └── subscribe.ts │ │ │ │ │ ├── __integration__ │ │ │ │ │ │ └── index.test.ts │ │ │ │ │ └── index.ts │ │ │ │ ├── logout.tsx │ │ │ │ ├── messages │ │ │ │ │ ├── __integration__ │ │ │ │ │ │ ├── index.test.ts │ │ │ │ │ │ └── indexMock.test.ts │ │ │ │ │ └── index.ts │ │ │ │ ├── previews │ │ │ │ │ ├── [previewClass] │ │ │ │ │ │ └── [previewFunction].ts │ │ │ │ │ ├── __test__ │ │ │ │ │ │ ├── __snapshots__ │ │ │ │ │ │ │ └── index.test.tsx.snap │ │ │ │ │ │ ├── index.test.tsx │ │ │ │ │ │ └── send.test.tsx │ │ │ │ │ ├── index.ts │ │ │ │ │ └── send.ts │ │ │ │ ├── render.ts │ │ │ │ ├── sendMail.ts │ │ │ │ ├── session.ts │ │ │ │ ├── unsubscribe │ │ │ │ │ ├── [memberId].ts │ │ │ │ │ └── __integration__ │ │ │ │ │ │ └── [memberId].test.ts │ │ │ │ └── users.ts │ │ │ ├── audiences │ │ │ │ └── index.tsx │ │ │ ├── index.tsx │ │ │ ├── intercepts │ │ │ │ ├── [interceptId].tsx │ │ │ │ └── mock.tsx │ │ │ ├── lists │ │ │ │ └── [id] │ │ │ │ │ └── subscribe.tsx │ │ │ ├── login.tsx │ │ │ ├── previews │ │ │ │ └── [[...path]].tsx │ │ │ ├── settings.tsx │ │ │ ├── signup.tsx │ │ │ └── unsubscribe │ │ │ │ └── [memberId].tsx │ │ ├── preview │ │ │ └── controllers │ │ │ │ ├── application.ts │ │ │ │ └── intercepts.tsx │ │ ├── session.d.ts │ │ ├── styles │ │ │ └── globals.css │ │ └── util │ │ │ ├── __test__ │ │ │ ├── __snapshots__ │ │ │ │ ├── mjml.test.tsx.snap │ │ │ │ ├── moduleManifestUtil.test.ts.snap │ │ │ │ └── renderTemplate.test.ts.snap │ │ │ ├── buildHandler.test.ts │ │ │ ├── mjml.test.tsx │ │ │ ├── moduleManifestUtil.test.ts │ │ │ ├── renderTemplate.test.ts │ │ │ ├── testUtils.ts │ │ │ └── validateApiKey.test.ts │ │ │ ├── analytics │ │ │ ├── __test__ │ │ │ │ └── analytics.test.ts │ │ │ ├── index.ts │ │ │ └── providers │ │ │ │ ├── AnalyticsProvider.d.ts │ │ │ │ ├── Axiom.ts │ │ │ │ ├── Posthog.ts │ │ │ │ ├── __test__ │ │ │ │ ├── Axiom.test.ts │ │ │ │ └── Posthog.test.ts │ │ │ │ └── index.ts │ │ │ ├── api │ │ │ ├── validate.ts │ │ │ └── validateMemberStatusInList.ts │ │ │ ├── buildHandler.ts │ │ │ ├── config │ │ │ ├── __test__ │ │ │ │ ├── __snapshots__ │ │ │ │ │ └── config.test.ts.snap │ │ │ │ └── config.test.ts │ │ │ └── index.ts │ │ │ ├── createMessage.ts │ │ │ ├── generators.ts │ │ │ ├── jsonStringifyError.ts │ │ │ ├── lists.ts │ │ │ ├── mjml.ts │ │ │ ├── moduleManifestUtil.ts │ │ │ ├── paths.ts │ │ │ ├── renderTemplate.ts │ │ │ ├── serverLogger.ts │ │ │ ├── session.ts │ │ │ ├── tailwind.ts │ │ │ ├── validate │ │ │ ├── validateApiKey.ts │ │ │ ├── validateMethod.ts │ │ │ └── validateTemplate.ts │ │ │ └── wrapError.ts │ ├── tailwind.config.js │ ├── theme.js │ └── tsconfig.json ├── core │ ├── CHANGELOG.md │ ├── package.json │ ├── src │ │ ├── __test__ │ │ │ ├── index.test.tsx │ │ │ ├── render.test.tsx │ │ │ └── testSetup.ts │ │ ├── const │ │ │ └── mailingCoreVersion.ts │ │ ├── index.ts │ │ ├── mjml-browser.d.ts │ │ ├── mjml.ts │ │ └── util │ │ │ ├── __test__ │ │ │ ├── __snapshots__ │ │ │ │ └── instrumentHtml.test.ts.snap │ │ │ └── instrumentHtml.test.ts │ │ │ ├── instrumentHtml.ts │ │ │ └── serverLogger.ts │ └── tsconfig.json └── web │ ├── .gitignore │ ├── CHANGELOG.md │ ├── README.md │ ├── components │ ├── Arrow.tsx │ ├── BlogLayout.tsx │ ├── DefaultLayout.tsx │ ├── Header.tsx │ ├── docs │ │ ├── DocsLayout.tsx │ │ ├── IndexButton.tsx │ │ └── NavLink.tsx │ ├── homepage │ │ ├── CircleJar.tsx │ │ ├── ExampleCard.tsx │ │ ├── H2.tsx │ │ ├── KeyButton.tsx │ │ ├── Li.tsx │ │ └── Social.tsx │ ├── hooks │ │ └── useHydrationFriendlyAsPath.tsx │ ├── mdx │ │ ├── A.tsx │ │ ├── Code.tsx │ │ ├── CodeBlock.tsx │ │ ├── CodeBlocks.tsx │ │ ├── H1.tsx │ │ ├── H2.tsx │ │ ├── H3.tsx │ │ ├── H4.tsx │ │ ├── Li.tsx │ │ ├── MDXComponents.tsx │ │ ├── P.tsx │ │ ├── Pre.tsx │ │ ├── Ul.tsx │ │ ├── highlight-js-styles │ │ │ └── vs2015.css │ │ └── util │ │ │ ├── __test__ │ │ │ └── getAnchor.test.ts │ │ │ └── getAnchor.tsx │ └── white-glove │ │ ├── ExampleCard.tsx │ │ ├── H2.tsx │ │ ├── KeyButton.tsx │ │ ├── Li.tsx │ │ ├── Pricing.tsx │ │ └── Subheading.tsx │ ├── emails │ ├── Newsletter.tsx │ ├── assets │ │ ├── discord.png │ │ ├── force-deliver.png │ │ ├── github.png │ │ ├── header-background.png │ │ ├── header-side.png │ │ ├── html-linter.png │ │ ├── logo-full.png │ │ ├── logo.png │ │ ├── mailing-lists.png │ │ ├── thanks.png │ │ ├── white-glove.png │ │ └── zero-point-nine.gif │ ├── components │ │ ├── Button.tsx │ │ ├── Footer.tsx │ │ ├── Header.tsx │ │ ├── Heading.tsx │ │ ├── Layout.tsx │ │ ├── Link.tsx │ │ ├── List.tsx │ │ ├── Spacer.tsx │ │ └── Text.tsx │ ├── index.ts │ ├── previews │ │ └── Newsletter.tsx │ ├── theme.ts │ └── util │ │ ├── assetUrl.ts │ │ └── cssHelpers.ts │ ├── mailing.config.json │ ├── netlify.toml │ ├── next-env.d.ts │ ├── next.config.mjs │ ├── package.json │ ├── pages │ ├── _app.tsx │ ├── _document.tsx │ ├── api │ │ ├── __integration__ │ │ │ └── newsletterSubscribers.test.ts │ │ └── newsletterSubscribers.ts │ ├── blog │ │ └── first-post.mdx │ ├── docs │ │ ├── building-templates.mdx │ │ ├── contributing.mdx │ │ ├── deploy.mdx │ │ ├── discord.mdx │ │ ├── index.mdx │ │ ├── lists.mdx │ │ ├── platform.mdx │ │ ├── remix.mdx │ │ ├── rest-api.mdx │ │ ├── sending-email.mdx │ │ ├── templates.mdx │ │ └── turborepo.mdx │ ├── index.tsx │ └── white-glove.tsx │ ├── postcss.config.js │ ├── prisma │ ├── __mocks__ │ │ └── index.ts │ ├── index.ts │ ├── migrations │ │ ├── 20220809190025_init │ │ │ └── migration.sql │ │ ├── 20220927220803_rename_users_to_newsletter_subscribers │ │ │ └── migration.sql │ │ └── migration_lock.toml │ └── schema.prisma │ ├── public │ ├── android-chrome-192x192.png │ ├── android-chrome-512x512.png │ ├── apple-touch-icon.png │ ├── browserconfig.xml │ ├── discord-icon.png │ ├── email-prefs-screenshot.png │ ├── favicon-16x16.png │ ├── favicon-32x32.png │ ├── favicon.ico │ ├── gh-icon.png │ ├── homepage │ │ ├── circle-jar │ │ │ ├── HowItWorksJar1.svg │ │ │ ├── HowItWorksJar2.svg │ │ │ └── HowItWorksJar3.svg │ │ ├── demo-theme-skinny.png │ │ ├── demo-theme.png │ │ ├── fynn-code-lg.png │ │ ├── fynn-code-sample.png │ │ ├── fynn-code-sm.png │ │ ├── fynn-code.png │ │ ├── fynn-screenshot.png │ │ ├── list-screenshot.png │ │ ├── prefs-screenshot.png │ │ ├── previewer-screenshot.png │ │ ├── testimonial-cv@2x.png │ │ ├── testimonial-email@2x.png │ │ ├── testimonial-gr@2x.jpeg │ │ ├── testimonial-sd@2x.png │ │ ├── testimonial-st@2x.png │ │ └── testimonial-wv@2x.png │ ├── icon-gh@2x.png │ ├── icon-twitter.svg │ ├── lockup-sans-serif.svg │ ├── mailing-icon-white.svg │ ├── mailing-icon.svg │ ├── mailing-logo.svg │ ├── mstile-144x144.png │ ├── mstile-150x150.png │ ├── mstile-310x150.png │ ├── mstile-310x310.png │ ├── mstile-70x70.png │ ├── og-image.jpg │ ├── og-twitter.jpg │ ├── safari-pinned-tab.svg │ ├── site.webmanifest │ ├── welcome-template │ │ ├── discord.png │ │ ├── github.png │ │ ├── logo-full.png │ │ └── logo.png │ └── white-glove │ │ ├── bbeam.png │ │ ├── bookbook.png │ │ ├── fynn.png │ │ ├── lancey.png │ │ ├── mailing.png │ │ ├── thoughtful-post.png │ │ ├── white-glove_og-image.jpg │ │ └── white-glove_og-twitter.jpg │ ├── styles │ ├── Home.module.css │ └── globals.css │ ├── tailwind.config.js │ └── tsconfig.json ├── scripts ├── assert-free-ports ├── generate-emails ├── newsletter │ └── send_newsletter.rb └── release ├── testSetup.integration.ts ├── testSetup.ts ├── testUtilIntegration.ts ├── tsconfig.json └── yarn.lock /.changeset/README.md: -------------------------------------------------------------------------------- 1 | # Changesets 2 | 3 | Hello and welcome! This folder has been automatically generated by `@changesets/cli`, a build tool that works 4 | with multi-package repos, or single-package repos to help you version and publish your code. You can 5 | find the full documentation for it [in our repository](https://github.com/changesets/changesets) 6 | 7 | We have a quick list of common questions to get you started engaging with this project in 8 | [our documentation](https://github.com/changesets/changesets/blob/main/docs/common-questions.md) 9 | -------------------------------------------------------------------------------- /.changeset/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://unpkg.com/@changesets/config@2.0.0/schema.json", 3 | "changelog": "@changesets/cli/changelog", 4 | "commit": false, 5 | "fixed": [], 6 | "linked": [], 7 | "access": "public", 8 | "baseBranch": "main", 9 | "updateInternalDependencies": "patch", 10 | "ignore": [] 11 | } 12 | -------------------------------------------------------------------------------- /.changeset/orange-dancers-whisper.md: -------------------------------------------------------------------------------- 1 | --- 2 | "mailing": patch 3 | "mailing-core": patch 4 | "web": patch 5 | --- 6 | 7 | bump peer dep to next 14 8 | -------------------------------------------------------------------------------- /.changeset/rich-adults-train.md: -------------------------------------------------------------------------------- 1 | --- 2 | "mailing": minor 3 | "mailing-core": minor 4 | --- 5 | 6 | remove anonymous telemetry and email collection 7 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | # Mailing platform 2 | MAILING_DATABASE_URL= 3 | MAILING_SESSION_PASSWORD= # generate a 32 character password for iron-session using https://1password.com/password-generator/ 4 | 5 | # Developing Mailing 6 | ## Database for packages/web 7 | WEB_DATABASE_URL= 8 | 9 | ## Sending mail 10 | MAILING_SES_USER= 11 | MAILING_SES_PASSWORD= 12 | 13 | ## Integration tests 14 | MAILING_DATABASE_URL_TEST= 15 | WEB_DATABASE_URL_TEST= 16 | # Note: MAILING_SESSION_PASSWORD must also be set for integration tests to run 17 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | **/dist/** 3 | **/generated/** 4 | **/runs/** 5 | .mailing 6 | -------------------------------------------------------------------------------- /.github/workflows/cypress.yml: -------------------------------------------------------------------------------- 1 | name: Cypress Tests 2 | 3 | on: 4 | push: 5 | pull_request: 6 | 7 | jobs: 8 | cypress: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - name: Checkout 12 | uses: actions/checkout@v3 13 | - name: Use Node.js 18.x 14 | uses: actions/setup-node@v3 15 | with: 16 | node-version: 18 17 | cache: "yarn" 18 | - name: Install dependencies 19 | run: yarn 20 | - name: Build the app 21 | run: yarn build 22 | - name: Cypress run 23 | uses: cypress-io/github-action@v4 24 | env: 25 | DEBUG: "@cypress/github-action" 26 | with: 27 | install: false 28 | start: yarn ci:server:nohup 29 | working-directory: packages/cli 30 | browser: chrome 31 | -------------------------------------------------------------------------------- /.github/workflows/eslint.yml: -------------------------------------------------------------------------------- 1 | name: Eslint 2 | on: 3 | push: 4 | pull_request: 5 | jobs: 6 | eslint: 7 | runs-on: ubuntu-latest 8 | steps: 9 | - uses: actions/checkout@v3 10 | - name: Use Node.js 18 11 | uses: actions/setup-node@v3 12 | with: 13 | node-version: 18 14 | cache: "yarn" 15 | - name: Install dependencies 16 | run: yarn 17 | - name: Lint 18 | run: yarn lint 19 | -------------------------------------------------------------------------------- /.github/workflows/rubocop.yml: -------------------------------------------------------------------------------- 1 | name: Rubocop 2 | on: 3 | push: 4 | pull_request: 5 | jobs: 6 | rubocop: 7 | runs-on: ubuntu-latest 8 | steps: 9 | - uses: actions/checkout@v3 10 | - uses: ruby/setup-ruby@v1 11 | with: 12 | ruby-version: 3.1.2 13 | bundler-cache: true 14 | - name: Run rubocop 15 | run: bundle exec rubocop 16 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | yarn lint-staged 5 | -------------------------------------------------------------------------------- /.lintstagedrc: -------------------------------------------------------------------------------- 1 | { 2 | "*.{js,jsx,ts,tsx}": "eslint --fix", 3 | "*.rb": "rubocop --autocorrect", 4 | "packages/cli/src/__mocks__/emails/**/*.{ts,tsx}": "scripts/generate-emails __mocks__/emails", 5 | "packages/cli/src/emails/**/*.{ts,tsx}": "scripts/generate-emails emails" 6 | } 7 | -------------------------------------------------------------------------------- /.rubocop.yml: -------------------------------------------------------------------------------- 1 | AllCops: 2 | NewCops: enable 3 | Exclude: 4 | - "vendor/**/*" 5 | - "**/vendor/**/*" 6 | - "node_modules/**/*" 7 | - "**/node_modules/**/*" 8 | 9 | Metrics/AbcSize: 10 | Max: 50 11 | 12 | Metrics/MethodLength: 13 | Max: 50 14 | 15 | Metrics/ClassLength: 16 | Max: 200 17 | 18 | Style/Documentation: 19 | Enabled: false 20 | 21 | Metrics/PerceivedComplexity: 22 | Max: 10 23 | 24 | Metrics/CyclomaticComplexity: 25 | Max: 10 26 | -------------------------------------------------------------------------------- /.tool-versions: -------------------------------------------------------------------------------- 1 | nodejs 18.18.0 2 | yarn 1.22.19 3 | ruby 3.1.2 4 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | source 'https://rubygems.org' 4 | 5 | group :development, :test do 6 | gem 'pry', '0.14.1' 7 | gem 'rubocop', '1.36' 8 | end 9 | -------------------------------------------------------------------------------- /Gemfile.lock: -------------------------------------------------------------------------------- 1 | GEM 2 | remote: https://rubygems.org/ 3 | specs: 4 | ast (2.4.2) 5 | coderay (1.1.3) 6 | json (2.6.2) 7 | method_source (1.0.0) 8 | parallel (1.22.1) 9 | parser (3.1.2.1) 10 | ast (~> 2.4.1) 11 | pry (0.14.1) 12 | coderay (~> 1.1) 13 | method_source (~> 1.0) 14 | rainbow (3.1.1) 15 | regexp_parser (2.6.0) 16 | rexml (3.2.5) 17 | rubocop (1.36.0) 18 | json (~> 2.3) 19 | parallel (~> 1.10) 20 | parser (>= 3.1.2.1) 21 | rainbow (>= 2.2.2, < 4.0) 22 | regexp_parser (>= 1.8, < 3.0) 23 | rexml (>= 3.2.5, < 4.0) 24 | rubocop-ast (>= 1.20.1, < 2.0) 25 | ruby-progressbar (~> 1.7) 26 | unicode-display_width (>= 1.4.0, < 3.0) 27 | rubocop-ast (1.21.0) 28 | parser (>= 3.1.1.0) 29 | ruby-progressbar (1.11.0) 30 | unicode-display_width (2.3.0) 31 | 32 | PLATFORMS 33 | arm64-darwin-21 34 | x86_64-darwin-21 35 | x86_64-linux 36 | 37 | DEPENDENCIES 38 | pry (= 0.14.1) 39 | rubocop (= 1.36) 40 | 41 | BUNDLED WITH 42 | 2.3.7 43 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Peter Sugihara 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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | packages/cli/README.md -------------------------------------------------------------------------------- /babel.config.json: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | ["@babel/react", { "runtime": "automatic" }], 4 | ["@babel/preset-typescript", { "isTSX": true, "allExtensions": true }], 5 | "@babel/preset-env" 6 | ] 7 | } 8 | -------------------------------------------------------------------------------- /docs/CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | ../packages/web/pages/docs/contributing.mdx -------------------------------------------------------------------------------- /docs/RELEASING.md: -------------------------------------------------------------------------------- 1 | ## Releasing new versions 2 | 3 | ### Release a beta version 4 | ```bash 5 | git checkout -b pre-$(date +%Y%m%d) 6 | yarn changeset pre enter next 7 | yarn changeset # if there is no new changeset 8 | yarn changeset version 9 | yarn release 10 | ``` 11 | 12 | ### Release a major/minor/patch version 13 | ```bash 14 | git checkout main 15 | yarn changeset # if there is no new changeset 16 | git add . && git commit 17 | yarn changeset version 18 | yarn release 19 | git commit -am v{NEW.VERSION.NUMBER} 20 | git push 21 | git checkout -b v{NEW.VERSION.NUMBER} 22 | git push # for posterity / future debugging 23 | ``` 24 | -------------------------------------------------------------------------------- /e2e/app.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative 'app/standalone_js' 4 | require_relative 'app/standalone_ts' 5 | require_relative 'app/turbo' 6 | require_relative 'app/next_ts' 7 | require_relative 'app/next_js' 8 | require_relative 'app/redwood_ts' 9 | require_relative 'app/redwood_js' 10 | require_relative 'app/remix_ts' 11 | require_relative 'app/remix_js' 12 | 13 | module App 14 | # does this framework use typescript? if so, e2e tests will perform typescript specific checks 15 | attr_reader :typescript 16 | 17 | CONFIGS = { 18 | standalone_js: App::StandaloneJs, 19 | standalone_ts: App::StandaloneTs, 20 | turbo: App::Turbo, 21 | next_ts: App::NextTs, 22 | next_js: App::NextJs, 23 | redwood_ts: App::RedwoodTs, 24 | redwood_js: App::RedwoodJs, 25 | remix_ts: App::RemixTs, 26 | remix_js: App::RemixJs 27 | }.freeze 28 | 29 | SKIPPED_APPS = %i[turbo].freeze 30 | 31 | def self.from_name(app_name) 32 | CONFIGS[app_name.to_sym] 33 | end 34 | end 35 | -------------------------------------------------------------------------------- /e2e/app/next_js.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative 'base' 4 | 5 | module App 6 | class NextJs < Base 7 | def initialize(root_dir, *args) 8 | super('next_js', root_dir, *args) 9 | end 10 | 11 | private 12 | 13 | def yarn_create! 14 | Dir.chdir(root_dir) do 15 | cmd = <<-STR.split("\n").map(&:strip).join(' ') 16 | yarn create next-app . 17 | --javascript 18 | --no-eslint --no-src-dir --no-experimental-app --import-alias='@/*' 19 | STR 20 | system_quiet(cmd) 21 | end 22 | end 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /e2e/app/next_ts.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative 'base' 4 | 5 | module App 6 | class NextTs < Base 7 | def initialize(root_dir, *args) 8 | @typescript = true 9 | super('next_ts', root_dir, *args) 10 | end 11 | 12 | private 13 | 14 | def yarn_create! 15 | Dir.chdir(root_dir) do 16 | cmd = <<-STR.split("\n").map(&:strip).join(' ') 17 | yarn create next-app . 18 | --typescript 19 | --no-eslint --no-src-dir --no-experimental-app --import-alias='@/*' 20 | STR 21 | system_quiet(cmd) 22 | end 23 | end 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /e2e/app/redwood_js.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative 'base' 4 | 5 | module App 6 | class RedwoodJs < Base 7 | def initialize(root_dir, *args) 8 | super('redwood_js', root_dir, *args) 9 | end 10 | 11 | private 12 | 13 | def yarn_create! 14 | Dir.chdir(root_dir) do 15 | system_quiet('yarn create redwood-app . --ts=false --no-git') 16 | 17 | # yarn add peer dependencies 18 | system_quiet('yarn add next react react-dom') 19 | end 20 | end 21 | 22 | def add_yarn_ci_scripts! 23 | super 24 | 25 | Dir.chdir(root_dir) do 26 | package_json = JSON.parse(File.read('package.json')) 27 | package_json['resolutions'] ||= {} 28 | package_json['resolutions']['@types/react'] = '^17' 29 | package_json['resolutions']['@types/react-dom'] = '^17' 30 | File.write('package.json', JSON.pretty_generate(package_json)) 31 | end 32 | end 33 | end 34 | end 35 | -------------------------------------------------------------------------------- /e2e/app/redwood_ts.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative 'base' 4 | 5 | module App 6 | class RedwoodTs < Base 7 | def initialize(root_dir, *args) 8 | @typescript = true 9 | @tsconfig_path = 'web/tsconfig.json' 10 | super('redwood_ts', root_dir, *args) 11 | end 12 | 13 | private 14 | 15 | def yarn_create! 16 | Dir.chdir(root_dir) do 17 | system_quiet('yarn create redwood-app . --typescript --no-git') 18 | 19 | # yarn add peer dependencies 20 | system_quiet('yarn add next react react-dom') 21 | end 22 | end 23 | 24 | def add_yarn_ci_scripts! 25 | super 26 | 27 | Dir.chdir(root_dir) do 28 | package_json = JSON.parse(File.read('package.json')) 29 | package_json['resolutions'] ||= {} 30 | package_json['resolutions']['@types/react'] = '^17' 31 | package_json['resolutions']['@types/react-dom'] = '^17' 32 | File.write('package.json', JSON.pretty_generate(package_json)) 33 | end 34 | end 35 | end 36 | end 37 | -------------------------------------------------------------------------------- /e2e/app/remix_js.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative 'base' 4 | 5 | module App 6 | class RemixJs < Base 7 | def initialize(root_dir, *args) 8 | super('remix_js', root_dir, *args) 9 | end 10 | 11 | private 12 | 13 | def yarn_create! 14 | Dir.chdir(root_dir) do 15 | system_quiet('yarn create remix . --template=remix --no-typescript --install') 16 | 17 | # yarn add peer dependencies 18 | system_quiet('yarn add next react react-dom') 19 | end 20 | end 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /e2e/app/remix_ts.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative 'base' 4 | 5 | module App 6 | class RemixTs < Base 7 | def initialize(root_dir, *args) 8 | @typescript = true 9 | super('remix_ts', root_dir, *args) 10 | end 11 | 12 | private 13 | 14 | def yarn_create! 15 | Dir.chdir(root_dir) do 16 | # install with the "remix" template 17 | system_quiet('yarn create remix . --template=remix-run/remix/templates/remix --typescript --install') 18 | 19 | ## variation: indie-stack is a different remix template that people use 20 | # system_quiet("yarn create remix . --template=remix-run/indie-stack --typescript --install") 21 | 22 | # yarn add peer dependencies 23 | system_quiet('yarn add next react react-dom') 24 | end 25 | end 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /e2e/app/standalone_js.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative 'base' 4 | 5 | module App 6 | class StandaloneJs < Base 7 | def initialize(root_dir, *args) 8 | super('standalone_js', root_dir, *args) 9 | end 10 | 11 | private 12 | 13 | def yarn_create! 14 | Dir.chdir(root_dir) do 15 | system_quiet('yarn init --yes') 16 | 17 | # yarn add peer dependencies 18 | system_quiet('yarn add next react react-dom') 19 | end 20 | end 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /e2e/app/standalone_ts.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative 'base' 4 | 5 | module App 6 | class StandaloneTs < Base 7 | def initialize(root_dir, *args) 8 | @typescript = true 9 | super('standalone_ts', root_dir, *args) 10 | end 11 | 12 | private 13 | 14 | def yarn_create! 15 | Dir.chdir(root_dir) do 16 | system_quiet('yarn init --yes') 17 | system_quiet('yarn add typescript && yarn tsc --init') 18 | 19 | # yarn add peer dependencies 20 | system_quiet('yarn add next react react-dom') 21 | end 22 | end 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /e2e/app/turbo.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative 'base' 4 | 5 | module App 6 | class Turbo < Base 7 | attr_writer :sub_dir 8 | 9 | def initialize(root_dir, *args) 10 | super('turbo', root_dir, *args) 11 | end 12 | 13 | def install_dir 14 | File.join(root_dir, 'apps/web') 15 | end 16 | 17 | private 18 | 19 | def yarn_create! 20 | Dir.chdir(root_dir) do 21 | system_quiet('npx create-turbo@latest --use-yarn .') 22 | end 23 | end 24 | 25 | def add_yarn_ci_scripts! 26 | super 27 | 28 | Dir.chdir(root_dir) do 29 | package_json = JSON.parse(File.read('package.json')) 30 | package_json['resolutions'] ||= {} 31 | package_json['resolutions']['@types/react'] = '^17' 32 | package_json['resolutions']['@types/react-dom'] = '^17' 33 | File.write('package.json', JSON.pretty_generate(package_json)) 34 | end 35 | end 36 | end 37 | end 38 | -------------------------------------------------------------------------------- /e2e/commands/run.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative '../helpers/system_utils' 4 | require_relative './setup' 5 | 6 | module Commands 7 | class Run 8 | include SystemUtils 9 | 10 | # @param [String] app_name 11 | def self.perform(app_name:, opts: {}) 12 | new(app_name: app_name, opts: opts) 13 | end 14 | 15 | # @param [String] app_name 16 | def initialize(app_name:, opts: {}) 17 | verify_mailing_port_is_free! 18 | config = Commands::Setup.perform(app_name: app_name, opts: opts) 19 | app = config.app 20 | 21 | # Run Jest Tests 22 | Dir.chdir(app.install_dir) do 23 | announce! "Running jest tests for #{app_name}", '🃏' 24 | system('yarn jest --rootDir=mailing_tests/jest --config mailing_tests/jest/jest.config.json') 25 | end 26 | 27 | app.run_mailing do 28 | # Run Cypress Tests 29 | announce! "Running cypress tests for #{app_name}", '🏃' 30 | system('yarn cypress run --project mailing_tests') 31 | end 32 | end 33 | end 34 | end 35 | -------------------------------------------------------------------------------- /e2e/commands/run_all.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative '../mailing' 4 | 5 | module Commands 6 | class RunAll 7 | def self.supported_frameworks 8 | App::CONFIGS.keys - App::SKIPPED_APPS 9 | end 10 | 11 | def self.perform(opts: {}) 12 | Mailing.build 13 | 14 | supported_frameworks.each do |app_name| 15 | Commands::Run.perform(app_name: app_name, opts: opts.merge('skip-build' => true)) 16 | end 17 | end 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /e2e/commands/update_snapshot.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative '../mailing' 4 | 5 | module Commands 6 | class UpdateSnapshot 7 | include SystemUtils 8 | 9 | def self.perform(opts: {}) 10 | app_name = opts['app'] || 'next_ts' 11 | new(app_name: app_name, opts: opts) 12 | end 13 | 14 | # @param [String] app_name 15 | def initialize(app_name:, opts: {}) 16 | verify_mailing_port_is_free! 17 | config = Commands::Setup.perform(app_name: app_name, opts: opts) 18 | app = config.app 19 | 20 | # Run Jest Tests 21 | Dir.chdir(app.install_dir) do 22 | announce! "Running jest tests with --updateSnapshot for #{app_name}", '🃏' 23 | system('yarn jest --rootDir=mailing_tests/jest --config mailing_tests/jest/jest.config.json --updateSnapshot') 24 | 25 | # copy snapshots back to mailing 26 | announce! 'Copying updated snapshots to the mailing project', '🥋' 27 | system(format('find ./mailing_tests -name \'*.snap\' | cpio -p %s', Config::E2E_ROOT)) 28 | end 29 | end 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /e2e/config.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class Config 4 | E2E_ROOT = File.expand_path(__dir__) 5 | CACHE_DIR = File.join(E2E_ROOT, 'cache') 6 | MAILING_TESTS_DIR = File.join(E2E_ROOT, 'mailing_tests') 7 | 8 | PROJECT_ROOT = File.expand_path("#{__dir__}/..") 9 | CLI_ROOT = File.join(PROJECT_ROOT, 'packages/cli') 10 | CORE_ROOT = File.join(PROJECT_ROOT, 'packages/core') 11 | 12 | TEST_ROOT = File.expand_path('/tmp/mailing_e2e') 13 | RUNS_DIR = File.expand_path("#{TEST_ROOT}/runs") 14 | end 15 | -------------------------------------------------------------------------------- /e2e/helpers/opts_parser.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | ## Option parsing 4 | module OptsParser 5 | def assign_opts! 6 | @opts = {} 7 | ARGV.each do |str| 8 | k, v = str.split('=') 9 | k.delete_prefix!('--') 10 | v = true if v.nil? 11 | @opts[k] = v 12 | end 13 | end 14 | 15 | def opt?(key) 16 | !!@opts[key] 17 | end 18 | 19 | def opt(key) 20 | @opts[key] 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /e2e/helpers/system_utils.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'socket' 4 | require_relative '../config' 5 | 6 | ## Utility stuff 7 | module SystemUtils 8 | NUM_RUNS_TO_KEEP = 5 9 | 10 | def system_quiet(cmd) 11 | if ENV['VERBOSE'] 12 | system(cmd) 13 | else 14 | system("#{cmd} > /dev/null") 15 | end 16 | end 17 | 18 | def announce!(text, emoji) 19 | 10.times { puts "\n" } 20 | puts "#{"#{emoji} " * 10}\n#{text}\n#{"#{emoji} " * 10}" 21 | end 22 | 23 | def cleanup! 24 | # Cleanup runs directory! 25 | dirs_to_cleanup = Array(Dir.glob("#{Config::RUNS_DIR}/*")) 26 | .sort { |a, b| b <=> a } 27 | .grep_v(/latest/)[NUM_RUNS_TO_KEEP..] 28 | 29 | dirs_to_cleanup&.each do |dir| 30 | puts "Cleaning up #{dir}" 31 | spawn("rm -rf #{dir}") 32 | end 33 | end 34 | 35 | def verify_mailing_port_is_free! 36 | # Verify that mailing is not running 37 | 38 | if TCPSocket.new('localhost', 3883) 39 | raise 'aborting without running tests... port 3883 is busy, is mailing already running?' 40 | end 41 | rescue Errno::ECONNREFUSED 42 | # the port is open. this is the expected case 43 | end 44 | end 45 | -------------------------------------------------------------------------------- /e2e/helpers/test_runner.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'English' 4 | require 'tmpdir' 5 | require 'json' 6 | require 'socket' 7 | 8 | require_relative 'helpers/test_runner_utils' 9 | require_relative 'helpers/opts_parser' 10 | require_relative 'app/next_ts' 11 | require_relative 'app/next_js' 12 | require_relative 'app/redwood_ts' 13 | require_relative 'app/redwood_js' 14 | require_relative 'app/remix_ts' 15 | require_relative 'app/remix_js' 16 | require_relative 'app/standalone' 17 | require_relative 'app/turbo' 18 | 19 | class TestRunner 20 | include TestRunnerUtils 21 | include OptsParser 22 | 23 | attr_reader :configs, :current_dir 24 | 25 | def initialize 26 | assign_opts! 27 | set_configs! 28 | verify_mailing_port_is_free! 29 | end 30 | 31 | def setup! 32 | setup_environment 33 | build_mailing 34 | end 35 | 36 | # @return [App] 37 | def build_app(app_name) 38 | klass = E2E_CONFIG[app_name.to_sym] 39 | tmp_dir_name = File.join(@current_dir, app_name.to_s) 40 | app = klass.new(tmp_dir_name, save_cache: opt?('save-cache')) 41 | app.setup! unless opt?('rerun') 42 | app 43 | end 44 | end 45 | -------------------------------------------------------------------------------- /e2e/mailing.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative 'helpers/system_utils' 4 | require_relative 'config' 5 | 6 | class Mailing 7 | extend SystemUtils 8 | 9 | def self.build(skip: false) 10 | # Install yarn dependencies and publish Cli and Core to yalc 11 | if skip 12 | puts 'Skipping build because skip-build flag is set' 13 | return 14 | end 15 | 16 | announce! 'Building mailing...', '🔨' 17 | 18 | Dir.chdir(Config::PROJECT_ROOT) do 19 | res = system_quiet('yarn build') 20 | raise 'yarn build failed' unless res 21 | end 22 | 23 | Dir.chdir(Config::CLI_ROOT) do 24 | system_quiet('npx yalc add') 25 | system_quiet('npx yalc publish') 26 | end 27 | 28 | Dir.chdir(Config::CORE_ROOT) do 29 | system_quiet('npx yalc add') 30 | system_quiet('npx yalc publish') 31 | end 32 | end 33 | end 34 | -------------------------------------------------------------------------------- /e2e/mailing_tests/cypress.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "cypress"; 2 | 3 | export default defineConfig({ 4 | e2e: { 5 | defaultCommandTimeout: 10000, 6 | baseUrl: "http://localhost:3883", 7 | setupNodeEvents(_on, _config) { 8 | // implement node event listeners here 9 | }, 10 | }, 11 | }); 12 | -------------------------------------------------------------------------------- /e2e/mailing_tests/cypress/e2e/index.cy.js: -------------------------------------------------------------------------------- 1 | describe("index page tests", () => { 2 | beforeEach(() => { 3 | cy.visit("/"); 4 | }); 5 | 6 | it("should redirect index to previewFunction with tree", () => { 7 | cy.location("pathname").should("eq", "/previews/Welcome/preview"); 8 | 9 | cy.contains("preview") 10 | .should("have.attr", "aria-selected", "true") 11 | .should("have.attr", "role", "treeitem"); 12 | cy.contains("Emails") 13 | .should("have.attr", "aria-expanded", "true") 14 | .should("have.attr", "aria-selected", "false") 15 | .should("have.attr", "role", "treeitem"); 16 | cy.contains("Compact view").click(); 17 | 18 | cy.get("[aria-selected=true]").contains("preview"); 19 | }); 20 | }); 21 | -------------------------------------------------------------------------------- /e2e/mailing_tests/cypress/support/e2e.js: -------------------------------------------------------------------------------- 1 | // *********************************************************** 2 | // This example support/e2e.ts is processed and 3 | // loaded automatically before your test files. 4 | // 5 | // This is a great place to put global configuration and 6 | // behavior that modifies Cypress. 7 | // 8 | // You can change the location of this file or turn off 9 | // automatically serving support files with the 10 | // 'supportFile' configuration option. 11 | // 12 | // You can read more here: 13 | // https://on.cypress.io/configuration 14 | // *********************************************************** 15 | 16 | // Import commands.js using ES2015 syntax: 17 | import "./commands"; 18 | 19 | // Alternatively you can use CommonJS syntax: 20 | // require('./commands') 21 | -------------------------------------------------------------------------------- /e2e/mailing_tests/jest/__snapshots__/exportPreviews.test.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`exportPreviews command cli --minify runs on templates 1`] = ` 4 | "mailing Exporting minified preview html to 5 | mailing ./previews_html/ 6 | mailing |-- welcome_preview.html 7 | mailing ✅ Processed 1 previews 8 | 9 | " 10 | `; 11 | 12 | exports[`exportPreviews command cli halts on lint errors runs on templates 1`] = ` 13 | "mailing Exporting preview html to 14 | mailing ./previews_html/ 15 | mailing |-- welcome_preview.html 16 | mailing ✅ Processed 1 previews 17 | 18 | " 19 | `; 20 | 21 | exports[`exportPreviews command cli runs on templates 1`] = ` 22 | "mailing Exporting preview html to 23 | mailing ./previews_html/ 24 | mailing |-- welcome_preview.html 25 | mailing ✅ Processed 1 previews 26 | 27 | " 28 | `; 29 | -------------------------------------------------------------------------------- /e2e/mailing_tests/jest/babel.config.json: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["@babel/preset-env"] 3 | } 4 | -------------------------------------------------------------------------------- /e2e/mailing_tests/jest/exportPreviews.test.js: -------------------------------------------------------------------------------- 1 | import execCli from "./util/execCli"; 2 | 3 | describe("exportPreviews command", () => { 4 | describe("cli", () => { 5 | it("runs on templates", async () => { 6 | const out = await execCli("export-previews --skip-lint"); 7 | expect(out).toMatchSnapshot(); 8 | }); 9 | }); 10 | 11 | describe("cli --minify", () => { 12 | it("runs on templates", async () => { 13 | const out = await execCli("export-previews --minify --skip-lint"); 14 | expect(out).toMatchSnapshot(); 15 | }); 16 | }); 17 | 18 | describe("cli halts on lint errors", () => { 19 | it("runs on templates", async () => { 20 | const out = await execCli("export-previews"); 21 | expect(out).toMatchSnapshot(); 22 | }); 23 | }); 24 | }); 25 | -------------------------------------------------------------------------------- /e2e/mailing_tests/jest/jest.config.json: -------------------------------------------------------------------------------- 1 | { 2 | "clearMocks": true, 3 | "transform": { 4 | "^.+\\.(js)$": "babel-jest" 5 | }, 6 | "testTimeout": 120000, 7 | "testMatch": ["/*.test.[jt]s?(x)"] 8 | } 9 | -------------------------------------------------------------------------------- /e2e/mailing_tests/jest/server.test.js: -------------------------------------------------------------------------------- 1 | import execCli from "./util/execCli"; 2 | 3 | describe("server command", () => { 4 | it("runs build", async () => { 5 | // expect it not to raise an error 6 | await execCli("server build"); 7 | }); 8 | }); 9 | -------------------------------------------------------------------------------- /e2e/mailing_tests/jest/util/execCli.js: -------------------------------------------------------------------------------- 1 | import { exec } from "child_process"; 2 | 3 | export default async function execCli(command, opts) { 4 | return new Promise((resolve, reject) => { 5 | const child = exec(`FORCE_COLOR=0 npx mailing ${command}`); 6 | let out = ""; 7 | let err = ""; 8 | child.stdout?.on("data", (stream) => { 9 | out += stream.toString(); 10 | if (opts?.debug) console.log(out); 11 | }); 12 | child.stderr?.on("data", (stream) => { 13 | err += stream.toString(); 14 | if (opts?.debug) console.log(out); 15 | }); 16 | child.on("error", reject); 17 | child.on("exit", (code) => { 18 | if (code) { 19 | console.log(out); 20 | console.error(err); 21 | reject(new Error(`${command} exited with code ${code}`)); 22 | } 23 | resolve(out); 24 | }); 25 | }); 26 | } 27 | -------------------------------------------------------------------------------- /jsdom.env.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-var-requires */ 2 | import { TestEnvironment } from "jest-environment-jsdom"; 3 | 4 | class CustomTestEnvironment extends TestEnvironment { 5 | async setup() { 6 | await super.setup(); 7 | // TextEncoder is required by node-html-parser 8 | if (typeof this.global.TextEncoder === "undefined") { 9 | const { TextEncoder } = require("util"); 10 | this.global.TextEncoder = TextEncoder; 11 | } 12 | 13 | // setImmediate is required by nodemailer 14 | if (typeof this.global.setImmediate === "undefined") { 15 | const { setImmediate } = require("timers"); 16 | this.global.setImmediate = setImmediate; 17 | } 18 | } 19 | } 20 | 21 | module.exports = CustomTestEnvironment; 22 | -------------------------------------------------------------------------------- /packages/cli/.gitignore: -------------------------------------------------------------------------------- 1 | cypress/videos 2 | cypress/screenshots 3 | out 4 | 5 | .mailing 6 | .next 7 | .vercel 8 | 9 | mailing.vercel.json 10 | -------------------------------------------------------------------------------- /packages/cli/.npmignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | .next 4 | -------------------------------------------------------------------------------- /packages/cli/bin.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | require("./dist/mailing.cjs.js"); 4 | -------------------------------------------------------------------------------- /packages/cli/cypress.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "cypress"; 2 | 3 | export default defineConfig({ 4 | e2e: { 5 | defaultCommandTimeout: 10000, 6 | baseUrl: "http://localhost:3883", 7 | setupNodeEvents(_on, _config) { 8 | // implement node event listeners here 9 | }, 10 | }, 11 | }); 12 | -------------------------------------------------------------------------------- /packages/cli/cypress/e2e/__integration__/audience.cy.integration.ts: -------------------------------------------------------------------------------- 1 | describe("audience", () => { 2 | beforeEach(() => { 3 | cy.task("db:reset"); 4 | cy.signup(); 5 | }); 6 | 7 | it("should show subscribers on the /audiences page", () => { 8 | // subscribe a user to the default list 9 | cy.visit("/settings"); 10 | cy.get("a").contains("Subscribe").click(); 11 | cy.get("input#email").type("ok@ok.com"); 12 | cy.get("button[type=submit]").click(); 13 | cy.get("body").should("contain", "Thanks for subscribing!"); 14 | 15 | // ok@ok.com should appear on the /audiences page 16 | cy.visit("/audiences"); 17 | cy.get(".table-data").should("contain", "ok@ok.com"); 18 | }); 19 | }); 20 | -------------------------------------------------------------------------------- /packages/cli/cypress/e2e/index.cy.js: -------------------------------------------------------------------------------- 1 | const { isBindingElement } = require("typescript"); 2 | 3 | describe("index page tests", () => { 4 | beforeEach(() => { 5 | cy.visit("/"); 6 | }); 7 | 8 | it("should redirect index to previewFunction with tree", () => { 9 | cy.location("pathname").should("eq", "/previews/Welcome/preview"); 10 | 11 | cy.contains("preview") 12 | .should("have.attr", "aria-selected", "true") 13 | .should("have.attr", "role", "treeitem"); 14 | cy.contains("Emails") 15 | .should("have.attr", "aria-expanded", "true") 16 | .should("have.attr", "aria-selected", "false") 17 | .should("have.attr", "role", "treeitem"); 18 | cy.contains("Compact view").click(); 19 | 20 | cy.get("[aria-selected=true]").contains("preview"); 21 | }); 22 | }); 23 | -------------------------------------------------------------------------------- /packages/cli/cypress/support/e2e.ts: -------------------------------------------------------------------------------- 1 | // *********************************************************** 2 | // This example support/e2e.ts is processed and 3 | // loaded automatically before your test files. 4 | // 5 | // This is a great place to put global configuration and 6 | // behavior that modifies Cypress. 7 | // 8 | // You can change the location of this file or turn off 9 | // automatically serving support files with the 10 | // 'supportFile' configuration option. 11 | // 12 | // You can read more here: 13 | // https://on.cypress.io/configuration 14 | // *********************************************************** 15 | 16 | // Import commands.js using ES2015 syntax: 17 | import "./commands"; 18 | 19 | // Alternatively you can use CommonJS syntax: 20 | // require('./commands') 21 | -------------------------------------------------------------------------------- /packages/cli/cypress/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": ["es5", "dom"], 5 | "types": ["cypress", "node"] 6 | }, 7 | "include": ["**/*.ts", "../cypress*.ts"] 8 | } 9 | -------------------------------------------------------------------------------- /packages/cli/next-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | 4 | // NOTE: This file should not be edited 5 | // see https://nextjs.org/docs/basic-features/typescript for more information. 6 | -------------------------------------------------------------------------------- /packages/cli/next.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | eslint: { 3 | ignoreDuringBuilds: !process.env.MM_DEV, 4 | }, 5 | }; 6 | -------------------------------------------------------------------------------- /packages/cli/postcss.config.js: -------------------------------------------------------------------------------- 1 | const path = require("path"); 2 | 3 | module.exports = { 4 | plugins: { 5 | tailwindcss: { 6 | config: path.join(__dirname, "tailwind.config.js"), 7 | }, 8 | autoprefixer: {}, 9 | }, 10 | }; 11 | -------------------------------------------------------------------------------- /packages/cli/prisma/__mocks__/index.ts: -------------------------------------------------------------------------------- 1 | import { PrismaClient } from "../generated/client"; 2 | import { mockDeep, mockReset, DeepMockProxy } from "jest-mock-extended"; 3 | 4 | import prisma from ".."; 5 | 6 | jest.mock("..", () => ({ 7 | __esModule: true, 8 | default: mockDeep(), 9 | })); 10 | 11 | beforeEach(() => { 12 | mockReset(prismaMock); 13 | }); 14 | 15 | export const prismaMock = prisma as unknown as DeepMockProxy; 16 | -------------------------------------------------------------------------------- /packages/cli/prisma/index.ts: -------------------------------------------------------------------------------- 1 | import { PrismaClient } from "./generated/client"; 2 | 3 | declare global { 4 | // eslint-disable-next-line no-var 5 | var prismaMailingCli: PrismaClient | undefined; 6 | } 7 | 8 | const prisma = global.prismaMailingCli || new PrismaClient(); 9 | 10 | if (process.env.NODE_ENV === "development") global.prismaMailingCli = prisma; 11 | 12 | export default prisma; 13 | -------------------------------------------------------------------------------- /packages/cli/prisma/migrations/20221019231924_create_lists/migration.sql: -------------------------------------------------------------------------------- 1 | -- CreateTable 2 | CREATE TABLE "List" ( 3 | "id" TEXT NOT NULL, 4 | "name" TEXT NOT NULL, 5 | "organizationId" TEXT NOT NULL, 6 | 7 | CONSTRAINT "List_pkey" PRIMARY KEY ("id") 8 | ); 9 | 10 | -- CreateIndex 11 | CREATE INDEX "List_organizationId_idx" ON "List"("organizationId"); 12 | 13 | -- AddForeignKey 14 | ALTER TABLE "List" ADD CONSTRAINT "List_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE RESTRICT ON UPDATE CASCADE; 15 | -------------------------------------------------------------------------------- /packages/cli/prisma/migrations/20221020043430_create_members/migration.sql: -------------------------------------------------------------------------------- 1 | -- CreateTable 2 | CREATE TABLE "Member" ( 3 | "id" TEXT NOT NULL, 4 | "email" TEXT NOT NULL, 5 | "listId" TEXT NOT NULL, 6 | "status" TEXT NOT NULL, 7 | 8 | CONSTRAINT "Member_pkey" PRIMARY KEY ("id") 9 | ); 10 | 11 | -- AddForeignKey 12 | ALTER TABLE "Member" ADD CONSTRAINT "Member_listId_fkey" FOREIGN KEY ("listId") REFERENCES "List"("id") ON DELETE RESTRICT ON UPDATE CASCADE; 13 | -------------------------------------------------------------------------------- /packages/cli/prisma/migrations/20221021232602_iterate_members/migration.sql: -------------------------------------------------------------------------------- 1 | /* 2 | Warnings: 3 | 4 | - A unique constraint covering the columns `[listId,email]` on the table `Member` will be added. If there are existing duplicate values, this will fail. 5 | - Changed the type of `status` on the `Member` table. No cast exists, the column would be dropped and recreated, which cannot be done if there is data, since the column is required. 6 | 7 | */ 8 | -- CreateEnum 9 | CREATE TYPE "MemberStatus" AS ENUM ('subscribed', 'unsubscribed'); 10 | 11 | -- AlterTable 12 | ALTER TABLE "Member" DROP COLUMN "status", 13 | ADD COLUMN "status" "MemberStatus" NOT NULL; 14 | 15 | -- CreateIndex 16 | CREATE UNIQUE INDEX "Member_listId_email_key" ON "Member"("listId", "email"); 17 | -------------------------------------------------------------------------------- /packages/cli/prisma/migrations/20221028184445_add_is_default_to_lists/migration.sql: -------------------------------------------------------------------------------- 1 | -- AlterTable 2 | ALTER TABLE "List" ADD COLUMN "isDefault" BOOLEAN NOT NULL DEFAULT false; 3 | -------------------------------------------------------------------------------- /packages/cli/prisma/migrations/20221104153624_member_and_list_timestamps/migration.sql: -------------------------------------------------------------------------------- 1 | /* 2 | Warnings: 3 | 4 | - Added the required column `updatedAt` to the `List` table without a default value. This is not possible if the table is not empty. 5 | - Added the required column `updatedAt` to the `Member` table without a default value. This is not possible if the table is not empty. 6 | 7 | */ 8 | -- AlterTable 9 | ALTER TABLE "List" ADD COLUMN "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, 10 | ADD COLUMN "updatedAt" TIMESTAMP(3) NOT NULL; 11 | 12 | -- AlterTable 13 | ALTER TABLE "Member" ADD COLUMN "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, 14 | ADD COLUMN "updatedAt" TIMESTAMP(3) NOT NULL; 15 | -------------------------------------------------------------------------------- /packages/cli/prisma/migrations/20221110210035_add_display_name/migration.sql: -------------------------------------------------------------------------------- 1 | /* 2 | Warnings: 3 | 4 | - A unique constraint covering the columns `[name]` on the table `List` will be added. If there are existing duplicate values, this will fail. 5 | - Added the required column `displayName` to the `List` table without a default value. This is not possible if the table is not empty. 6 | 7 | */ 8 | -- AlterTable 9 | ALTER TABLE "List" ADD COLUMN "displayName" TEXT NOT NULL; 10 | 11 | -- CreateIndex 12 | CREATE UNIQUE INDEX "List_name_key" ON "List"("name"); 13 | -------------------------------------------------------------------------------- /packages/cli/prisma/migrations/migration_lock.toml: -------------------------------------------------------------------------------- 1 | # Please do not edit this file manually 2 | # It should be added in your version-control system (i.e. Git) 3 | provider = "postgresql" -------------------------------------------------------------------------------- /packages/cli/public/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sofn-xyz/mailing/90af52dd9d67089430be485449858b95322f9fca/packages/cli/public/favicon-16x16.png -------------------------------------------------------------------------------- /packages/cli/public/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sofn-xyz/mailing/90af52dd9d67089430be485449858b95322f9fca/packages/cli/public/favicon-32x32.png -------------------------------------------------------------------------------- /packages/cli/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sofn-xyz/mailing/90af52dd9d67089430be485449858b95322f9fca/packages/cli/public/favicon.ico -------------------------------------------------------------------------------- /packages/cli/src/__mocks__/emails-js/components/Button.jsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { MjmlButton } from "@faire/mjml-react"; 3 | import { colors, fontSize, borderRadius, lineHeight, spacing } from "../theme"; 4 | 5 | export default function Button(props) { 6 | return ( 7 | <> 8 | 19 | 30 | 31 | ); 32 | } 33 | -------------------------------------------------------------------------------- /packages/cli/src/__mocks__/emails-js/components/Divider.jsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { MjmlDivider } from "@faire/mjml-react"; 3 | import { colors } from "../theme"; 4 | 5 | const defaultProps = { 6 | borderColor: colors.neutral600, 7 | borderStyle: "dotted", 8 | borderWidth: "1px", 9 | }; 10 | export default function Divider(props) { 11 | return ; 12 | } 13 | -------------------------------------------------------------------------------- /packages/cli/src/__mocks__/emails-js/components/Footer.jsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { MjmlSection, MjmlColumn, MjmlText } from "@faire/mjml-react"; 3 | import { EMAIL_PREFERENCES_URL } from "mailing-core"; 4 | import { colors, fontSize, spacing } from "../theme"; 5 | 6 | export default function Footer({ includeUnsubscribe }) { 7 | return ( 8 | 9 | 10 | 16 | © {new Date().getFullYear()} BookBook 17 | {includeUnsubscribe && ( 18 | <> 19 |   ·   20 | 26 | Unsubscribe 27 | 28 | 29 | )} 30 | 31 | 32 | 33 | ); 34 | } 35 | -------------------------------------------------------------------------------- /packages/cli/src/__mocks__/emails-js/components/Header.jsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { MjmlSection, MjmlColumn, MjmlImage } from "@faire/mjml-react"; 3 | 4 | const Header = ({ loose }) => { 5 | return ( 6 | 7 | 8 | 16 | 17 | 18 | ); 19 | }; 20 | export default Header; 21 | -------------------------------------------------------------------------------- /packages/cli/src/__mocks__/emails-js/components/Heading.jsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import Text from "./Text"; 3 | import { fontFamily, lineHeight, fontWeight, fontSize } from "../theme"; 4 | 5 | const defaultProps = { 6 | fontFamily: fontFamily.sans, 7 | fontWeight: fontWeight.normal, 8 | lineHeight: lineHeight.tight, 9 | fontSize: fontSize.lg, 10 | }; 11 | export default function Heading(props) { 12 | return ( 13 | 14 | {props.children} 15 | 16 | ); 17 | } 18 | -------------------------------------------------------------------------------- /packages/cli/src/__mocks__/emails-js/components/List.jsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { MjmlRaw } from "@faire/mjml-react"; 3 | import { themeDefaults } from "../theme"; 4 | 5 | export default function List({ items }) { 6 | return ( 7 | 8 | 9 | 10 | 17 | {items.map((item, index) => ( 18 | 19 | 27 | 28 | 29 | ))} 30 |
25 | • 26 | {item}
31 | 32 | 33 |
34 | ); 35 | } 36 | -------------------------------------------------------------------------------- /packages/cli/src/__mocks__/emails-js/components/Text.jsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { MjmlText } from "@faire/mjml-react"; 3 | 4 | export default function Text({ children, maxWidth, ...props }) { 5 | if (maxWidth) { 6 | return ( 7 | 8 |
{children}
9 |
10 | ); 11 | } else 12 | return ( 13 | 14 | {children} 15 | 16 | ); 17 | } 18 | -------------------------------------------------------------------------------- /packages/cli/src/__mocks__/emails-js/index.js: -------------------------------------------------------------------------------- 1 | import nodemailer from "nodemailer"; 2 | import { buildSendMail } from "mailing-core"; 3 | 4 | const transport = nodemailer.createTransport({ 5 | pool: true, 6 | host: "smtp.example.com", 7 | port: 465, 8 | secure: true, 9 | auth: { 10 | user: "username", 11 | pass: "password", 12 | }, 13 | }); 14 | const sendMail = buildSendMail({ 15 | transport, 16 | defaultFrom: "replace@me.with.your.com", 17 | configPath: "./mailing.config.json", 18 | }); 19 | export default sendMail; 20 | -------------------------------------------------------------------------------- /packages/cli/src/__mocks__/emails-js/previews/AccountCreated.jsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import AccountCreated from "../AccountCreated"; 3 | 4 | export function accountCreated() { 5 | return ; 6 | } 7 | -------------------------------------------------------------------------------- /packages/cli/src/__mocks__/emails-js/previews/NewSignIn.jsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import NewSignIn from "../NewSignIn"; 3 | import List from "../components/List"; 4 | 5 | export function newSignIn() { 6 | return ( 7 | 12 | We noticed a new sign-in to your BookBook account on a Mac device. If 13 | this was you, you don’t need to do anything. If not, please reply to 14 | this email and we’ll help you secure your account. 15 | 16 | } 17 | bulletedList={ 18 | 27 | } 28 | /> 29 | ); 30 | } 31 | -------------------------------------------------------------------------------- /packages/cli/src/__mocks__/emails-js/previews/ResetPassword.jsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import ResetPassword from "../ResetPassword"; 3 | 4 | export function resetPassword() { 5 | return ( 6 | 10 | We’ve received your request to change your password. Use the link 11 | below to set up a new password for your account. This link is only 12 | usable once! If you need to, you can reinitiate the password process 13 | again here. 14 | 15 | } 16 | ctaText="Reset Password" 17 | /> 18 | ); 19 | } 20 | -------------------------------------------------------------------------------- /packages/cli/src/__mocks__/emails/components/Button.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { MjmlButton } from "@faire/mjml-react"; 3 | 4 | import { colors, fontSize, borderRadius, lineHeight, spacing } from "../theme"; 5 | 6 | type ButtonProps = React.ComponentProps; 7 | 8 | export default function Button(props: ButtonProps) { 9 | return ( 10 | <> 11 | 22 | 33 | 34 | ); 35 | } 36 | -------------------------------------------------------------------------------- /packages/cli/src/__mocks__/emails/components/Divider.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { MjmlDivider } from "@faire/mjml-react"; 3 | import { colors } from "../theme"; 4 | 5 | type DividerProps = React.ComponentProps; 6 | 7 | const defaultProps = { 8 | borderColor: colors.neutral600, 9 | borderStyle: "dotted", 10 | borderWidth: "1px", 11 | }; 12 | 13 | export default function Divider(props: DividerProps) { 14 | return ; 15 | } 16 | -------------------------------------------------------------------------------- /packages/cli/src/__mocks__/emails/components/Footer.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { MjmlSection, MjmlColumn, MjmlText } from "@faire/mjml-react"; 3 | import { EMAIL_PREFERENCES_URL } from "mailing-core"; 4 | import { colors, fontSize, spacing } from "../theme"; 5 | 6 | type FooterProps = { 7 | includeUnsubscribe?: boolean; 8 | }; 9 | 10 | export default function Footer({ includeUnsubscribe }: FooterProps) { 11 | return ( 12 | 13 | 14 | 20 | © {new Date().getFullYear()} BookBook 21 | {includeUnsubscribe && ( 22 | <> 23 |   ·   24 | 30 | Unsubscribe 31 | 32 | 33 | )} 34 | 35 | 36 | 37 | ); 38 | } 39 | -------------------------------------------------------------------------------- /packages/cli/src/__mocks__/emails/components/Header.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { MjmlSection, MjmlColumn, MjmlImage } from "@faire/mjml-react"; 3 | 4 | type HeaderProps = { 5 | loose?: boolean; 6 | }; 7 | 8 | const Header: React.FC = ({ loose }) => { 9 | return ( 10 | 11 | 12 | 20 | 21 | 22 | ); 23 | }; 24 | 25 | export default Header; 26 | -------------------------------------------------------------------------------- /packages/cli/src/__mocks__/emails/components/Heading.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import Text from "./Text"; 3 | import { fontFamily, lineHeight, fontWeight, fontSize } from "../theme"; 4 | 5 | type HeadingProps = React.ComponentProps; 6 | 7 | const defaultProps = { 8 | fontFamily: fontFamily.sans, 9 | fontWeight: fontWeight.normal, 10 | lineHeight: lineHeight.tight, 11 | fontSize: fontSize.lg, 12 | }; 13 | 14 | export default function Heading(props: HeadingProps) { 15 | return ( 16 | 17 | {props.children} 18 | 19 | ); 20 | } 21 | -------------------------------------------------------------------------------- /packages/cli/src/__mocks__/emails/components/List.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { MjmlRaw } from "@faire/mjml-react"; 3 | 4 | import { themeDefaults } from "../theme"; 5 | 6 | type ListProps = { 7 | items: string[]; 8 | } & React.ComponentProps; 9 | 10 | export default function List({ items }: ListProps) { 11 | return ( 12 | 13 | 14 | 15 | 22 | {items.map((item, index) => ( 23 | 24 | 32 | 33 | 34 | ))} 35 |
30 | • 31 | {item}
36 | 37 | 38 |
39 | ); 40 | } 41 | -------------------------------------------------------------------------------- /packages/cli/src/__mocks__/emails/components/Text.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { MjmlText } from "@faire/mjml-react"; 3 | 4 | type TextProps = { 5 | maxWidth?: number; 6 | } & React.ComponentProps; 7 | 8 | export default function Text({ children, maxWidth, ...props }: TextProps) { 9 | if (maxWidth) { 10 | return ( 11 | 12 |
{children}
13 |
14 | ); 15 | } else 16 | return ( 17 | 18 | {children} 19 | 20 | ); 21 | } 22 | -------------------------------------------------------------------------------- /packages/cli/src/__mocks__/emails/index.ts: -------------------------------------------------------------------------------- 1 | import nodemailer from "nodemailer"; 2 | import { buildSendMail } from "mailing-core"; 3 | 4 | const transport = nodemailer.createTransport({ 5 | pool: true, 6 | host: "smtp.example.com", 7 | port: 465, 8 | secure: true, // use TLS 9 | auth: { 10 | user: "username", 11 | pass: "password", 12 | }, 13 | }); 14 | 15 | const sendMail = buildSendMail({ 16 | transport, 17 | defaultFrom: "replace@me.with.your.com", 18 | configPath: "./mailing.config.json", 19 | }); 20 | 21 | export default sendMail; 22 | -------------------------------------------------------------------------------- /packages/cli/src/__mocks__/emails/previews/AccountCreated.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import AccountCreated from "../AccountCreated"; 3 | 4 | export function accountCreated() { 5 | return ; 6 | } 7 | -------------------------------------------------------------------------------- /packages/cli/src/__mocks__/emails/previews/NewSignIn.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import NewSignIn from "../NewSignIn"; 3 | import List from "../components/List"; 4 | 5 | export function newSignIn() { 6 | return ( 7 | 12 | We noticed a new sign-in to your BookBook account on a Mac device. If 13 | this was you, you don’t need to do anything. If not, please reply to 14 | this email and we’ll help you secure your account. 15 | 16 | } 17 | bulletedList={ 18 | 27 | } 28 | /> 29 | ); 30 | } 31 | -------------------------------------------------------------------------------- /packages/cli/src/__mocks__/emails/previews/ResetPassword.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import ResetPassword from "../ResetPassword"; 3 | 4 | export function resetPassword() { 5 | return ( 6 | 10 | We’ve received your request to change your password. Use the link 11 | below to set up a new password for your account. This link is only 12 | usable once! If you need to, you can reinitiate the password process 13 | again here. 14 | 15 | } 16 | ctaText="Reset Password" 17 | /> 18 | ); 19 | } 20 | -------------------------------------------------------------------------------- /packages/cli/src/__mocks__/moduleManifest.ts: -------------------------------------------------------------------------------- 1 | import sendMail from "./emails"; 2 | 3 | import AccountCreated from "./emails/AccountCreated"; 4 | import NewSignIn from "./emails/NewSignIn"; 5 | import Reservation from "./emails/Reservation"; 6 | import ResetPassword from "./emails/ResetPassword"; 7 | 8 | import * as AccountCreatedPreview from "./emails/previews/AccountCreated"; 9 | import * as NewSignInPreview from "./emails/previews/NewSignIn"; 10 | import * as ReservationPreview from "./emails/previews/Reservation"; 11 | import * as ResetPasswordPreview from "./emails/previews/ResetPassword"; 12 | 13 | const previews = { 14 | AccountCreated: AccountCreatedPreview, 15 | NewSignIn: NewSignInPreview, 16 | Reservation: ReservationPreview, 17 | ResetPassword: ResetPasswordPreview, 18 | }; 19 | const templates = { 20 | AccountCreated, 21 | NewSignIn, 22 | Reservation, 23 | ResetPassword, 24 | }; 25 | const config = {}; 26 | const moduleManifest = { sendMail, config, templates, previews }; 27 | 28 | export { sendMail, config, templates, previews }; 29 | export default moduleManifest; 30 | -------------------------------------------------------------------------------- /packages/cli/src/commands/__test__/execCli.ts: -------------------------------------------------------------------------------- 1 | import { exec } from "child_process"; 2 | 3 | export async function execCli(command: string, opts?: { debug: boolean }) { 4 | return new Promise((resolve, reject) => { 5 | const child = exec( 6 | `cd packages/cli && FORCE_COLOR=0 ${__dirname}/../../dev.js ${command}` 7 | ); 8 | let out = ""; 9 | let err = ""; 10 | child.stdout?.on("data", (stream) => { 11 | out += stream.toString(); 12 | if (opts?.debug) console.log(out); 13 | }); 14 | child.stderr?.on("data", (stream) => { 15 | err += stream.toString(); 16 | if (opts?.debug) console.log(out); 17 | }); 18 | child.on("error", reject); 19 | child.on("exit", (code) => { 20 | if (code) { 21 | if (opts?.debug) { 22 | console.log(out); 23 | console.error(err); 24 | } 25 | reject(new Error(err)); 26 | } 27 | resolve(out); 28 | }); 29 | }); 30 | } 31 | 32 | export function execCliChild(command: string, _opts?: { debug: boolean }) { 33 | return exec(`FORCE_COLOR=0 ${__dirname}/../../dev.js ${command}`); 34 | } 35 | -------------------------------------------------------------------------------- /packages/cli/src/commands/__test__/server.test.ts: -------------------------------------------------------------------------------- 1 | import { execCli } from "./execCli"; 2 | 3 | jest.mock("../../util/serverLogger"); 4 | 5 | describe("server command", () => { 6 | describe("cli", () => { 7 | it("builds", async () => { 8 | const out = await execCli( 9 | "server build --emails-dir ./src/__mocks__/emails" 10 | ); 11 | expect(out).toContain("Compiled successfully"); 12 | expect(out).toContain("Finalizing page optimization..."); 13 | expect(out).toContain("First Load JS shared by all"); 14 | }); 15 | }); 16 | }); 17 | -------------------------------------------------------------------------------- /packages/cli/src/commands/preview/preview.ts: -------------------------------------------------------------------------------- 1 | import { ArgumentsCamelCase } from "yargs"; 2 | import { buildHandler } from "../../util/buildHandler"; 3 | import { defaults } from "../../util/config"; 4 | import startPreviewServer from "./server/start"; 5 | 6 | export type PreviewArgs = ArgumentsCamelCase<{ 7 | port?: number; 8 | quiet?: boolean; 9 | emailsDir?: string; 10 | }>; 11 | 12 | export const command = "preview"; 13 | 14 | export const describe = "start the email preview server"; 15 | 16 | export const builder = { 17 | port: { 18 | default: defaults().port, 19 | description: "what port to start the preview server on", 20 | }, 21 | quiet: { 22 | default: defaults().quiet, 23 | description: "quiet mode (don't open browser after starting)", 24 | boolean: true, 25 | }, 26 | "emails-dir": { 27 | default: defaults().emailsDir, 28 | description: "the directory of your email templates", 29 | }, 30 | }; 31 | 32 | export const handler = buildHandler(async (argv: PreviewArgs) => { 33 | if (undefined === argv.port) throw new Error("port option is not set"); 34 | if (undefined === argv.quiet) throw new Error("quiet option is not set"); 35 | 36 | await startPreviewServer(); 37 | }); 38 | -------------------------------------------------------------------------------- /packages/cli/src/commands/preview/server/__test__/start.test.ts: -------------------------------------------------------------------------------- 1 | import startPreviewServer from "../start"; 2 | import * as config from "../../../../util/config"; 3 | import http from "http"; 4 | import * as paths from "../../../../util/paths"; 5 | 6 | jest.useFakeTimers(); 7 | 8 | describe("start", () => { 9 | it("should throw an error if it can't find the previews directory", async () => { 10 | const mockHttpServer = jest.fn(); 11 | jest.spyOn(config, "getConfig").mockImplementation(() => { 12 | return { 13 | emailsDir: "./packages/cli/src/__mocks__/emails", 14 | quiet: true, 15 | port: 3883, 16 | }; 17 | }); 18 | 19 | jest.spyOn(http, "createServer").mockImplementation(mockHttpServer); 20 | 21 | jest.spyOn(paths, "getPreviewsDirectory").mockImplementation(() => null); 22 | await expect(async () => { 23 | await startPreviewServer(); 24 | }).rejects.toThrow("previews directory does not exist"); 25 | expect(mockHttpServer).not.toHaveBeenCalled(); 26 | }); 27 | }); 28 | -------------------------------------------------------------------------------- /packages/cli/src/commands/preview/server/templates/feModuleManifest.template.ejs: -------------------------------------------------------------------------------- 1 | import config from "../../mailing.config.json"; 2 | 3 | const feManifest = { config }; 4 | export { config }; 5 | export default feManifest; 6 | -------------------------------------------------------------------------------- /packages/cli/src/commands/preview/server/templates/moduleManifest.template.ejs: -------------------------------------------------------------------------------- 1 | import config from "../../mailing.config.json"; 2 | import sendMail from "<%= relativePathToEmailsDir %>"; 3 | sendMail.config = config; 4 | 5 | // template imports 6 | <%= templateImports.join("\n") %> 7 | 8 | // preview imports 9 | <%= previewImports.join("\n") %> 10 | 11 | const previews = { <%= previewConsts.join(", ") %> }; 12 | const templates = { <%= templateModuleNames.join(", ") %> }; 13 | const moduleManifest = { sendMail, templates, previews }; 14 | 15 | export { sendMail, config, templates, previews }; 16 | export default moduleManifest; 17 | -------------------------------------------------------------------------------- /packages/cli/src/commands/util/__test__/getNodeModulesDirsFrom.test.ts: -------------------------------------------------------------------------------- 1 | import { sep } from "path"; 2 | import { getNodeModulesDirsFrom } from "../getNodeModulesDirsFrom"; 3 | 4 | describe("getNodeModulesDirsFrom", () => { 5 | it("should return an array with all the node_modules folders", () => { 6 | const output = getNodeModulesDirsFrom("."); 7 | expect(output.length).toBeGreaterThan(0); 8 | 9 | // for esbuild to look in the right place, it must end in "/*" 10 | const regexp = new RegExp(`${sep}node_modules${sep}\\*$`); 11 | expect(output[0]).toMatch(regexp); 12 | }); 13 | }); 14 | -------------------------------------------------------------------------------- /packages/cli/src/commands/util/getNodeModulesDirsFrom.ts: -------------------------------------------------------------------------------- 1 | import { resolve, join, sep } from "path"; 2 | 3 | // traversing up from startPath to the root, for each directory append a "node_modules" directory 4 | export function getNodeModulesDirsFrom(startPath: string) { 5 | const nodeModulesDirs = []; 6 | 7 | const pathDepth = resolve(startPath).split(sep).length; 8 | let i = pathDepth; 9 | 10 | do { 11 | nodeModulesDirs.push( 12 | resolve(join(startPath, `..${sep}`.repeat(i - 1), "node_modules", "*")) 13 | ); 14 | } while (--i); 15 | 16 | return nodeModulesDirs; 17 | } 18 | -------------------------------------------------------------------------------- /packages/cli/src/commands/util/registerRequireHooks.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-var-requires */ 2 | export default function registerRequireHooks() { 3 | if (process.env.MM_DEV) return; 4 | require("esbuild-register/dist/node").register({ 5 | jsx: "automatic", 6 | target: "node14", 7 | }); 8 | } 9 | -------------------------------------------------------------------------------- /packages/cli/src/components/FormError.tsx: -------------------------------------------------------------------------------- 1 | import { ReactNode } from "react"; 2 | 3 | export default function FormError(props: { children?: ReactNode }) { 4 | if (!props.children) return null; 5 | 6 | return ( 7 |
8 | {props.children} 9 |
10 | ); 11 | } 12 | -------------------------------------------------------------------------------- /packages/cli/src/components/FormSuccess.tsx: -------------------------------------------------------------------------------- 1 | import { ReactNode } from "react"; 2 | 3 | export default function FormSuccess(props: { children?: ReactNode }) { 4 | if (!props.children) return null; 5 | 6 | return ( 7 |
8 | {props.children} 9 |
10 | ); 11 | } 12 | -------------------------------------------------------------------------------- /packages/cli/src/components/HamburgerContext.tsx: -------------------------------------------------------------------------------- 1 | import { useState, useMemo, createContext } from "react"; 2 | 3 | type HamburgerContextProps = { 4 | hamburgerOpen: boolean; 5 | setHamburgerOpen: (open: boolean) => void; 6 | }; 7 | 8 | export const HamburgerContext = createContext({ 9 | hamburgerOpen: false, 10 | setHamburgerOpen: () => {}, 11 | }); 12 | 13 | type HamburgerProviderProps = { 14 | children: React.ReactNode; 15 | }; 16 | 17 | export function HamburgerProvider({ children }: HamburgerProviderProps) { 18 | const [hamburgerOpen, setHamburgerOpen] = useState(false); 19 | 20 | const value = useMemo( 21 | () => ({ hamburgerOpen, setHamburgerOpen }), 22 | [hamburgerOpen] 23 | ); 24 | 25 | return ( 26 | 27 | {children} 28 | 29 | ); 30 | } 31 | -------------------------------------------------------------------------------- /packages/cli/src/components/MjmlErrors.tsx: -------------------------------------------------------------------------------- 1 | type MjmlErrorsProps = { 2 | errors: Array; 3 | }; 4 | 5 | const MjmlErrors: React.FC = ({ errors }) => { 6 | return ( 7 |
8 |

MJML Errors

9 |
10 | Please resolve the following MJML errors in your email before continuing 11 |
12 |
    13 | {errors.map((error, i) => ( 14 |
  • {error.formattedMessage}
  • 15 | ))} 16 |
17 |
18 | ); 19 | }; 20 | 21 | export default MjmlErrors; 22 | -------------------------------------------------------------------------------- /packages/cli/src/components/NavBar/NavBarButton.tsx: -------------------------------------------------------------------------------- 1 | import cx from "classnames"; 2 | import Link from "next/link"; 3 | import { colors } from "../../util/tailwind"; 4 | 5 | type NavBarButtonProps = { 6 | active: boolean; 7 | href: string; 8 | Icon: React.FC; 9 | name: string; 10 | }; 11 | 12 | const NavBar: React.FC = ({ active, href, Icon, name }) => { 13 | return ( 14 | 15 | 26 | 27 | 28 | 29 | ); 30 | }; 31 | 32 | export default NavBar; 33 | -------------------------------------------------------------------------------- /packages/cli/src/components/NavBar/__test__/NavBar.test.tsx: -------------------------------------------------------------------------------- 1 | import NavBar from "../NavBar"; 2 | import { setup } from "../../../util/__test__/testUtils"; 3 | 4 | jest.mock("next/router", () => ({ 5 | useRouter() { 6 | return { 7 | route: "/previews/[[...path]]", 8 | pathname: "", 9 | query: "", 10 | asPath: "", 11 | }; 12 | }, 13 | })); 14 | 15 | describe("NavBar", () => { 16 | beforeAll(() => { 17 | (process.env as any).HOME_FEATURE_FLAG = "1"; 18 | (process.env as any).AUDIENCE_FEATURE_FLAG = "1"; 19 | }); 20 | 21 | it("render with correct current page", () => { 22 | const { getByRole } = setup(test); 23 | const nav = getByRole("navigation"); 24 | expect(nav).toBeVisible(); 25 | const homeLink = getByRole("link", { name: "Home" }); 26 | expect(homeLink).toBeVisible(); 27 | expect(homeLink.ariaCurrent).toBe(undefined); 28 | const previewsLink = getByRole("link", { 29 | name: "Previews", 30 | current: "page", 31 | }); 32 | expect(previewsLink).toBeVisible(); 33 | }); 34 | }); 35 | -------------------------------------------------------------------------------- /packages/cli/src/components/Watermark.tsx: -------------------------------------------------------------------------------- 1 | import Link from "next/link"; 2 | import LogoMarkSmall from "./icons/LogoMarkSmall"; 3 | 4 | const Watermark: React.FC = () => { 5 | return ( 6 | 20 | ); 21 | }; 22 | 23 | export default Watermark; 24 | -------------------------------------------------------------------------------- /packages/cli/src/components/__test__/HTMLLint.test.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { setup } from "../../util/__test__/testUtils"; 3 | import HTMLLint from "../HtmlLint"; 4 | 5 | describe("HTMLLint", () => { 6 | const data: HtmlLintError[] = [ 7 | { 8 | message: "Oh no!", 9 | }, 10 | { 11 | message: "Oh no again!", 12 | }, 13 | ]; 14 | 15 | it("should show error count and toggle error list visibility", async () => { 16 | const { findByText, user, getByRole } = setup(); 17 | 18 | const button = await findByText("2 HTML lint errors."); 19 | 20 | // list is hidden 21 | const listText = getByRole("list", { hidden: true }).textContent; 22 | expect(listText).toBe("1. Oh no!2. Oh no again!"); 23 | 24 | await user.click(button); 25 | 26 | // list is visible 27 | getByRole("list", { hidden: false }); 28 | 29 | // hide it again 30 | await user.click(button); 31 | getByRole("list", { hidden: true }); 32 | }); 33 | }); 34 | -------------------------------------------------------------------------------- /packages/cli/src/components/hooks/useLiveReload.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useRef } from "react"; 2 | import io, { Socket } from "socket.io-client"; 3 | 4 | export default function useLiveReload(onShouldReload: () => void) { 5 | const socketRef = useRef(null); 6 | 7 | useEffect(() => { 8 | if (process.env.NODE_ENV === 'production') { 9 | return; 10 | } 11 | 12 | // This is required to make navigating between previews work 13 | onShouldReload(); 14 | 15 | if (!socketRef.current) { 16 | socketRef.current = io(); 17 | } 18 | const socket = socketRef.current; 19 | 20 | socket.on("connect", () => { 21 | console.debug("Connected to live reload server"); 22 | }); 23 | 24 | socket.on("reload", () => { 25 | console.debug("Reloading..."); 26 | onShouldReload(); 27 | }); 28 | return function cleanupSocket() { 29 | socket.off(); 30 | }; 31 | }, [onShouldReload]); 32 | } 33 | -------------------------------------------------------------------------------- /packages/cli/src/components/hooks/usePreviewPath.tsx: -------------------------------------------------------------------------------- 1 | import { NextRouter, useRouter } from "next/router"; 2 | 3 | export default function usePreviewPath(): { 4 | previewClass?: string; 5 | previewFunction?: string; 6 | router: NextRouter; 7 | } { 8 | const router = useRouter(); 9 | const path = router?.query.path as string[] | undefined; 10 | const [previewClass, previewFunction] = path || []; 11 | return { previewClass, previewFunction, router }; 12 | } 13 | -------------------------------------------------------------------------------- /packages/cli/src/components/icons/IconAnalytics.tsx: -------------------------------------------------------------------------------- 1 | export default function IconAnalytics({ fill }: IconProps) { 2 | return ( 3 | 4 | 10 | 11 | ); 12 | } 13 | -------------------------------------------------------------------------------- /packages/cli/src/components/icons/IconAudience.tsx: -------------------------------------------------------------------------------- 1 | export default function IconAudience({ fill }: IconProps) { 2 | return ( 3 | 4 | 5 | 9 | 10 | 14 | 15 | ); 16 | } 17 | -------------------------------------------------------------------------------- /packages/cli/src/components/icons/IconClose.tsx: -------------------------------------------------------------------------------- 1 | export default function IconClose() { 2 | return ( 3 | 10 | 14 | 15 | ); 16 | } 17 | -------------------------------------------------------------------------------- /packages/cli/src/components/icons/IconDesktop.tsx: -------------------------------------------------------------------------------- 1 | export default function IconDesktop({ fill }: IconProps) { 2 | return ( 3 | 10 | 16 | 17 | ); 18 | } 19 | -------------------------------------------------------------------------------- /packages/cli/src/components/icons/IconEye.tsx: -------------------------------------------------------------------------------- 1 | export default function IconEye({ fill }: IconProps) { 2 | return ( 3 | 4 | 10 | 11 | ); 12 | } 13 | -------------------------------------------------------------------------------- /packages/cli/src/components/icons/IconGear.tsx: -------------------------------------------------------------------------------- 1 | export default function IconGear({ fill }: IconProps) { 2 | return ( 3 | 4 | 8 | 9 | ); 10 | } 11 | -------------------------------------------------------------------------------- /packages/cli/src/components/icons/IconHome.tsx: -------------------------------------------------------------------------------- 1 | export default function IconHome({ fill }: IconProps) { 2 | return ( 3 | 4 | 8 | 9 | ); 10 | } 11 | -------------------------------------------------------------------------------- /packages/cli/src/components/icons/IconMobile.tsx: -------------------------------------------------------------------------------- 1 | export default function IconMobile({ fill }: IconProps) { 2 | return ( 3 | 10 | 16 | 17 | ); 18 | } 19 | -------------------------------------------------------------------------------- /packages/cli/src/components/icons/IconQuestion.tsx: -------------------------------------------------------------------------------- 1 | export default function IconQuestion() { 2 | return ( 3 | 10 | 14 | 15 | ); 16 | } 17 | -------------------------------------------------------------------------------- /packages/cli/src/components/icons/IconSend.tsx: -------------------------------------------------------------------------------- 1 | export default function IconSend() { 2 | return ( 3 | 10 | 16 | 17 | ); 18 | } 19 | -------------------------------------------------------------------------------- /packages/cli/src/components/icons/IconWarning.tsx: -------------------------------------------------------------------------------- 1 | export default function IconWarning() { 2 | return ( 3 | 4 | 5 | 9 | 10 | ); 11 | } 12 | -------------------------------------------------------------------------------- /packages/cli/src/components/icons/icons.d.ts: -------------------------------------------------------------------------------- 1 | type IconProps = { 2 | fill: string; 3 | }; 4 | -------------------------------------------------------------------------------- /packages/cli/src/components/ui/Button.tsx: -------------------------------------------------------------------------------- 1 | import Link from "next/link"; 2 | import cx from "classnames"; 3 | import React from "react"; 4 | 5 | type ButtonProps = { 6 | text: string; 7 | href?: string; 8 | type?: "button" | "submit" | "reset"; 9 | onClick?: (e: React.MouseEvent) => void; 10 | small?: boolean; 11 | white?: boolean; 12 | full?: boolean; 13 | disabled?: boolean; 14 | }; 15 | 16 | const Button: React.FC = ({ 17 | text, 18 | href, 19 | onClick, 20 | type, 21 | small, 22 | white, 23 | full, 24 | disabled, 25 | }) => { 26 | const sharedClasses = cx( 27 | "rounded-2xl border-transparent font-bold leading-none text-black ease-in duration-150 w-full", 28 | { 29 | "text-sm pt-2 pb-3 px-3": small, 30 | "text-base pt-3 pb-4 px-4": !small, 31 | "bg-white": white, 32 | "bg-blue": !white, 33 | "w-full": full, 34 | "": !full, 35 | } 36 | ); 37 | return href ? ( 38 | 39 | {text} 40 | 41 | ) : ( 42 | 50 | ); 51 | }; 52 | 53 | export default Button; 54 | -------------------------------------------------------------------------------- /packages/cli/src/components/ui/Input.tsx: -------------------------------------------------------------------------------- 1 | import { forwardRef } from "react"; 2 | 3 | type InputProps = { 4 | label: string; 5 | placeholder?: string; 6 | type: "text" | "email" | "password"; 7 | name: string; 8 | id?: string; 9 | }; 10 | 11 | const Input = forwardRef( 12 | ({ label, placeholder, type, name, id }, ref) => ( 13 | <> 14 | 20 | 28 | 29 | ) 30 | ); 31 | 32 | Input.displayName = "Input"; 33 | 34 | export default Input; 35 | -------------------------------------------------------------------------------- /packages/cli/src/components/ui/OutlineButton.tsx: -------------------------------------------------------------------------------- 1 | import Link from "next/link"; 2 | import cx from "classnames"; 3 | 4 | type OutlineButtonProps = { 5 | text: string; 6 | href?: string; 7 | type?: "button" | "submit" | "reset"; 8 | onClick?: () => void; 9 | small?: boolean; 10 | }; 11 | 12 | const OutlineButton: React.FC = ({ 13 | text, 14 | href, 15 | onClick, 16 | type, 17 | small, 18 | }) => { 19 | const sharedClasses = cx( 20 | "rounded-2xl border border-emerald-700 bg-transparent font-bold leading-none text-green-300 hover:border-green-300 ease-in duration-150 px-4 inline-block", 21 | { 22 | "text-sm pt-3 pb-3.5": small, 23 | "text-base pt-3 pb-4": !small, 24 | } 25 | ); 26 | return href ? ( 27 | 28 | {text} 29 | 30 | ) : ( 31 | 34 | ); 35 | }; 36 | 37 | export default OutlineButton; 38 | -------------------------------------------------------------------------------- /packages/cli/src/const/mailingVersion.ts: -------------------------------------------------------------------------------- 1 | export const MAILING_VERSION = "1.0.1"; 2 | -------------------------------------------------------------------------------- /packages/cli/src/custom-styled-jsx.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /packages/cli/src/custom.d.ts: -------------------------------------------------------------------------------- 1 | type Intercept = { 2 | id: string; 3 | html: string; 4 | to?: string | string[]; 5 | from?: string | { name: string; address: string }; 6 | subject?: string; 7 | cc?: string | string[]; 8 | bcc?: string | string[]; 9 | }; 10 | 11 | type SendPreviewRequestBody = { 12 | to: string; 13 | previewFunction: string; 14 | previewClass: string; 15 | }; 16 | 17 | type SendPreviewResponseBody = { 18 | error?: string; 19 | }; 20 | 21 | type ShowPreviewResponseBody = { 22 | errors: MjmlError[]; 23 | htmlLint: HtmlLintError[]; 24 | html: string; 25 | }; 26 | 27 | type MjmlError = { 28 | line: number; 29 | message: string; 30 | tagName: string; 31 | formattedMessage: string; 32 | }; 33 | 34 | type HtmlLintError = { 35 | message: string; 36 | }; 37 | 38 | type ViewMode = "desktop" | "mobile" | "html"; 39 | 40 | type MailingConfig = { 41 | emailsDir?: string; 42 | port?: number; 43 | quiet?: boolean; 44 | }; 45 | -------------------------------------------------------------------------------- /packages/cli/src/dev.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | // This script can be used for quick cli development without compilation steps. 4 | 5 | // Make stack traces really big! 6 | Error.stackTraceLimit = Infinity; 7 | 8 | process.env.MM_DEV = 1; 9 | 10 | require("esbuild-register/dist/node").register({ 11 | jsx: "automatic", 12 | target: "node14", 13 | }); 14 | require("./index.ts"); 15 | -------------------------------------------------------------------------------- /packages/cli/src/emails-js/components/Button.jsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import cx from "classnames"; 3 | import { MjmlButton } from "@faire/mjml-react"; 4 | import { 5 | colors, 6 | fontSize, 7 | borderRadius, 8 | lineHeight, 9 | fontWeight, 10 | } from "../theme"; 11 | 12 | export default function Button(props) { 13 | return ( 14 | <> 15 | 27 | 28 | ); 29 | } 30 | -------------------------------------------------------------------------------- /packages/cli/src/emails-js/components/Heading.jsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import Text from "./Text"; 3 | import { fontFamily, lineHeight, fontWeight, fontSize } from "../theme"; 4 | 5 | const defaultProps = { 6 | fontFamily: fontFamily.sans, 7 | fontWeight: fontWeight.normal, 8 | lineHeight: lineHeight.tight, 9 | fontSize: fontSize.xl, 10 | }; 11 | export default function Heading(props) { 12 | return ( 13 | 14 | {props.children} 15 | 16 | ); 17 | } 18 | -------------------------------------------------------------------------------- /packages/cli/src/emails-js/components/Link.jsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { colors } from "../theme"; 3 | 4 | const getHrefPropsFromProps = (props) => { 5 | return JSON.parse( 6 | JSON.stringify({ 7 | href: props.href, 8 | rel: props.rel, 9 | target: props.target, 10 | }) 11 | ); 12 | }; 13 | const getStylePropsFromProps = (props) => { 14 | return JSON.parse( 15 | JSON.stringify({ 16 | color: props.color, 17 | fontFamily: props.fontFamily, 18 | fontSize: props.fontSize, 19 | fontStyle: props.fontStyle, 20 | fontWeight: props.fontWeight, 21 | letterSpacing: props.letterSpacing, 22 | height: props.height, 23 | textDecoration: props.textDecoration, 24 | textTransform: props.textTransform, 25 | align: props.align, 26 | }) 27 | ); 28 | }; 29 | export default function Link({ children, ...props }) { 30 | return ( 31 | 40 | {children} 41 | 42 | ); 43 | } 44 | -------------------------------------------------------------------------------- /packages/cli/src/emails-js/components/Text.jsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import cx from "classnames"; 3 | import { MjmlText } from "@faire/mjml-react"; 4 | 5 | export default function Text({ children, maxWidth, ...props }) { 6 | if (maxWidth) { 7 | return ( 8 | 9 |
{children}
10 |
11 | ); 12 | } else 13 | return ( 14 | 15 | {children} 16 | 17 | ); 18 | } 19 | -------------------------------------------------------------------------------- /packages/cli/src/emails-js/index.js: -------------------------------------------------------------------------------- 1 | import nodemailer from "nodemailer"; 2 | import { buildSendMail } from "mailing-core"; 3 | 4 | const transport = nodemailer.createTransport({ 5 | pool: true, 6 | host: "smtp.example.com", 7 | port: 465, 8 | secure: true, 9 | auth: { 10 | user: "username", 11 | pass: "password", 12 | }, 13 | }); 14 | const sendMail = buildSendMail({ 15 | transport, 16 | defaultFrom: "replace@me.with.your.com", 17 | configPath: "./mailing.config.json", 18 | }); 19 | export default sendMail; 20 | -------------------------------------------------------------------------------- /packages/cli/src/emails-js/previews/Welcome.jsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import Welcome from "../Welcome"; 3 | 4 | export function preview() { 5 | return ; 6 | } 7 | -------------------------------------------------------------------------------- /packages/cli/src/emails/components/Button.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import cx from "classnames"; 3 | import { MjmlButton } from "@faire/mjml-react"; 4 | 5 | import { 6 | colors, 7 | fontSize, 8 | borderRadius, 9 | lineHeight, 10 | fontWeight, 11 | } from "../theme"; 12 | 13 | type ButtonProps = React.ComponentProps; 14 | 15 | export default function Button(props: ButtonProps) { 16 | return ( 17 | <> 18 | 30 | 31 | ); 32 | } 33 | -------------------------------------------------------------------------------- /packages/cli/src/emails/components/Heading.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import Text from "./Text"; 3 | import { fontFamily, lineHeight, fontWeight, fontSize } from "../theme"; 4 | 5 | type HeadingProps = React.ComponentProps; 6 | 7 | const defaultProps = { 8 | fontFamily: fontFamily.sans, 9 | fontWeight: fontWeight.normal, 10 | lineHeight: lineHeight.tight, 11 | fontSize: fontSize.xl, 12 | }; 13 | 14 | export default function Heading(props: HeadingProps) { 15 | return ( 16 | 17 | {props.children} 18 | 19 | ); 20 | } 21 | -------------------------------------------------------------------------------- /packages/cli/src/emails/components/Text.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import cx from "classnames"; 3 | import { MjmlText } from "@faire/mjml-react"; 4 | 5 | type TextProps = { 6 | maxWidth?: number; 7 | } & React.ComponentProps; 8 | 9 | export default function Text({ children, maxWidth, ...props }: TextProps) { 10 | if (maxWidth) { 11 | return ( 12 | 13 |
{children}
14 |
15 | ); 16 | } else 17 | return ( 18 | 19 | {children} 20 | 21 | ); 22 | } 23 | -------------------------------------------------------------------------------- /packages/cli/src/emails/index.ts: -------------------------------------------------------------------------------- 1 | import nodemailer from "nodemailer"; 2 | import { buildSendMail } from "mailing-core"; 3 | 4 | const transport = nodemailer.createTransport({ 5 | pool: true, 6 | host: "smtp.example.com", 7 | port: 465, 8 | secure: true, // use TLS 9 | auth: { 10 | user: "username", 11 | pass: "password", 12 | }, 13 | }); 14 | 15 | const sendMail = buildSendMail({ 16 | transport, 17 | defaultFrom: "replace@me.with.your.com", 18 | configPath: "./mailing.config.json", 19 | }); 20 | 21 | export default sendMail; 22 | -------------------------------------------------------------------------------- /packages/cli/src/emails/previews/Welcome.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import Welcome from "../Welcome"; 3 | 4 | export function preview() { 5 | return ; 6 | } 7 | -------------------------------------------------------------------------------- /packages/cli/src/feManifest.ts: -------------------------------------------------------------------------------- 1 | const config = {} as MailingConfig; 2 | 3 | const manifest = { config }; 4 | export { config }; 5 | export default manifest; 6 | -------------------------------------------------------------------------------- /packages/cli/src/index.ts: -------------------------------------------------------------------------------- 1 | import { existsSync } from "fs-extra"; 2 | import "dotenv/config"; 3 | import yargs from "yargs/yargs"; 4 | import * as init from "./commands/init"; 5 | import * as preview from "./commands/preview/preview"; 6 | import * as exportPreviews from "./commands/exportPreviews"; 7 | import * as server from "./commands/server"; 8 | import { MAILING_CONFIG_FILE } from "./util/config"; 9 | import { readJSONverbose } from "./util/paths"; 10 | 11 | const config = existsSync(MAILING_CONFIG_FILE) 12 | ? readJSONverbose(MAILING_CONFIG_FILE) 13 | : {}; 14 | 15 | // prettier-ignore 16 | void yargs(process.argv.slice(2)) 17 | .config(config) 18 | .command(init) 19 | .command(preview) 20 | .command(exportPreviews) 21 | .command(server) 22 | .help() 23 | .argv; 24 | -------------------------------------------------------------------------------- /packages/cli/src/mjml-browser.d.ts: -------------------------------------------------------------------------------- 1 | declare module "mjml-browser" { 2 | const transform: ( 3 | vml: string, 4 | options?: { 5 | beautify?: boolean; 6 | minify?: boolean; 7 | keepComments?: boolean; 8 | validationLevel: "strict" | "soft" | "skip"; 9 | } 10 | ) => { 11 | json: MjmlBlockItem; 12 | html: string; 13 | errors: string[]; 14 | }; 15 | export default transform; 16 | } 17 | 18 | interface MjmlBlockItem { 19 | file: string; 20 | absoluteFilePath: string; 21 | line: number; 22 | includedIn: any[]; 23 | tagName: string; 24 | children: IChildrenItem[]; 25 | attributes: IAttributes; 26 | content?: string; 27 | } 28 | interface IChildrenItem { 29 | file?: string; 30 | absoluteFilePath?: string; 31 | line: number; 32 | includedIn: any[]; 33 | tagName: string; 34 | children?: IChildrenItem[]; 35 | attributes: IAttributes; 36 | content?: string; 37 | inline?: "inline"; 38 | } 39 | interface IAttributes { 40 | [key: string]: any; 41 | } 42 | -------------------------------------------------------------------------------- /packages/cli/src/moduleManifest.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * This file is only directly used in development and tests. 3 | * In production is it overwritten by the build process. 4 | */ 5 | 6 | import sendMailFromEmails from "./emails"; 7 | import Welcome from "./emails/Welcome"; 8 | import * as WelcomePreview from "./emails/previews/Welcome"; 9 | 10 | const sendMail = sendMailFromEmails; 11 | const previews = { 12 | Welcome: WelcomePreview, 13 | }; 14 | const templates = { 15 | Welcome, 16 | }; 17 | const config = {}; 18 | 19 | const manifest = { sendMail, config, templates, previews }; 20 | export { sendMail, config, templates, previews }; 21 | export default manifest; 22 | -------------------------------------------------------------------------------- /packages/cli/src/next-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | 4 | // NOTE: This file should not be edited 5 | // see https://nextjs.org/docs/basic-features/typescript for more information. 6 | -------------------------------------------------------------------------------- /packages/cli/src/pages/_app.tsx: -------------------------------------------------------------------------------- 1 | import type { AppProps } from "next/app"; 2 | import type { User } from "../../prisma/generated/client"; 3 | import Head from "next/head"; 4 | import { HamburgerProvider } from "../components/HamburgerContext"; 5 | import NavBar from "../components/NavBar/NavBar"; 6 | import "../styles/globals.css"; 7 | 8 | export default function Mailing({ 9 | Component, 10 | pageProps, 11 | }: AppProps<{ user?: User }>) { 12 | return ( 13 | 14 | 15 | Mailing 16 | 17 | {pageProps.user ? ( 18 | 19 | 20 | 21 | ) : ( 22 | 23 | )} 24 | 25 | ); 26 | } 27 | -------------------------------------------------------------------------------- /packages/cli/src/pages/analytics.tsx: -------------------------------------------------------------------------------- 1 | import { withSessionSsr } from "../util/session"; 2 | import { NextPage } from "next"; 3 | import { InferGetServerSidePropsType } from "next"; 4 | 5 | export const getServerSideProps = withSessionSsr<{ user: any }>( 6 | async function getServerSideProps({ req }) { 7 | const user = req.session.user; 8 | 9 | if (!user) { 10 | return { 11 | redirect: { 12 | destination: "/", 13 | permanent: false, 14 | }, 15 | }; 16 | } 17 | 18 | return { 19 | props: { 20 | user: req.session.user, 21 | }, 22 | }; 23 | } 24 | ); 25 | 26 | const Analytics: NextPage< 27 | InferGetServerSidePropsType 28 | > = (props) => { 29 | return ( 30 | <> 31 |

Analytics - hi {props.user?.email}

32 | 33 | ); 34 | }; 35 | 36 | export default Analytics; 37 | -------------------------------------------------------------------------------- /packages/cli/src/pages/api/__integration__/logout.test.ts: -------------------------------------------------------------------------------- 1 | import { apiGetApiKeys } from "./util/apiKeys"; 2 | import { apiLogin } from "./util/login"; 3 | import { apiLogout } from "./util/logout"; 4 | 5 | describe("logout", () => { 6 | describe("logged out", () => { 7 | it("does nothing", async () => { 8 | const { response: apiKeysResLoggedOut } = await apiGetApiKeys(); 9 | expect(apiKeysResLoggedOut.status).toBe(404); 10 | const { response: logoutRes } = await apiLogout(); 11 | expect(logoutRes.status).toBe(200); 12 | }); 13 | }); 14 | 15 | describe("logged in", () => { 16 | it("logs out", async () => { 17 | await apiLogin(); 18 | const { response: apiKeysRes } = await apiGetApiKeys(); 19 | expect(apiKeysRes.status).toBe(200); 20 | const { response: logoutRes } = await apiLogout(); 21 | expect(logoutRes.status).toBe(200); 22 | const { response: apiKeysResLoggedOut } = await apiGetApiKeys(); 23 | expect(apiKeysResLoggedOut.status).toBe(404); 24 | }); 25 | }); 26 | }); 27 | -------------------------------------------------------------------------------- /packages/cli/src/pages/api/__integration__/render.test.ts: -------------------------------------------------------------------------------- 1 | import { apiRender, ApiRenderGet } from "./util/render"; 2 | 3 | describe("render", () => { 4 | it("GET should 200", async () => { 5 | const instance = new ApiRenderGet(); 6 | const { response } = await instance.perform(); 7 | 8 | const data = await response.json(); 9 | expect(response.status).toBe(200); 10 | expect(data.html).toBeDefined; 11 | expect(data.html).toMatch(/Thank you/); 12 | expect(data.subject).toEqual('Thank you for installing Mailing :)'); 13 | }); 14 | 15 | it("POST should 200", async () => { 16 | const { response } = await apiRender(); 17 | const data = await response.json(); 18 | expect(response.status).toBe(200); 19 | expect(data.html).toBeDefined; 20 | expect(data.html).toMatch(/Thank you/); 21 | expect(data.subject).toEqual('Thank you for installing Mailing :)'); 22 | }); 23 | }); 24 | -------------------------------------------------------------------------------- /packages/cli/src/pages/api/__integration__/util/apiKeys.ts: -------------------------------------------------------------------------------- 1 | import { Api } from "./index"; 2 | import { assertIntegrationTestEnv } from "./assertIntegrationTestEnv"; 3 | 4 | assertIntegrationTestEnv(); 5 | 6 | export async function apiCreateApiKey() { 7 | const instance = new ApiPostApiKeys(); 8 | return instance.perform(); 9 | } 10 | export async function apiGetApiKeys() { 11 | const instance = new ApiGetApiKeys(); 12 | return instance.perform(); 13 | } 14 | 15 | export class ApiGetApiKeys extends Api { 16 | path = "/api/apiKeys"; 17 | method = "GET"; 18 | } 19 | 20 | export class ApiPostApiKeys extends Api { 21 | path = "/api/apiKeys"; 22 | } 23 | -------------------------------------------------------------------------------- /packages/cli/src/pages/api/__integration__/util/assertIntegrationTestEnv.ts: -------------------------------------------------------------------------------- 1 | // You should use this like so in any helper that uses prisma to manipulate the test database: 2 | // 3 | // import { assertIntegrationTestEnv } "path/to/assertIntegrationTestEnv"; 4 | // assertIntegrationTestEnv(); 5 | 6 | export function assertIntegrationTestEnv() { 7 | if ( 8 | !process.env.MAILING_DATABASE_URL?.match(/test$/) || 9 | !process.env.WEB_DATABASE_URL?.match(/test$/) 10 | ) 11 | throw new Error( 12 | `refusing to run against non-test databases process.env.MAILING_DATABASE_URL: ${process.env.MAILING_DATABASE_URL} process.env.WEB_DATABASE_URL: ${process.env.WEB_DATABASE_URL}` 13 | ); 14 | } 15 | -------------------------------------------------------------------------------- /packages/cli/src/pages/api/__integration__/util/createOrganizationDefaultListAndApiKey.ts: -------------------------------------------------------------------------------- 1 | import prisma from "../../../../../prisma"; 2 | 3 | export async function createOrganizationDefaultListAndApiKey() { 4 | const organization = await prisma.organization.create({ 5 | data: { 6 | name: "My Test Co " + Math.random(), 7 | }, 8 | }); 9 | 10 | const apiKey = await prisma.apiKey.create({ 11 | data: { 12 | organizationId: organization.id, 13 | }, 14 | }); 15 | 16 | const defaultList = await prisma.list.create({ 17 | data: { 18 | organizationId: organization.id, 19 | isDefault: true, 20 | displayName: "Default", 21 | name: "default", 22 | }, 23 | }); 24 | 25 | return { 26 | apiKey, 27 | organization, 28 | defaultList, 29 | }; 30 | } 31 | -------------------------------------------------------------------------------- /packages/cli/src/pages/api/__integration__/util/createUser.ts: -------------------------------------------------------------------------------- 1 | import { Api } from "./index"; 2 | 3 | interface CreateUserFormData { 4 | email: string; 5 | password: string; 6 | } 7 | 8 | export async function apiCreateUser() { 9 | const instance = new ApiCreateUser(); 10 | return instance.perform(); 11 | } 12 | 13 | export class ApiCreateUser extends Api { 14 | path = "/api/users"; 15 | 16 | formData = { 17 | email: `ok${Math.random()}@ok.com`, 18 | password: "okokokokokokokok", 19 | }; 20 | } 21 | -------------------------------------------------------------------------------- /packages/cli/src/pages/api/__integration__/util/hooks/click.ts: -------------------------------------------------------------------------------- 1 | import { Api } from "../index"; 2 | 3 | interface HookClickFormData { 4 | url: string; 5 | messageId?: string; 6 | } 7 | 8 | export async function apiHookClick({ messageId }: { messageId?: string } = {}) { 9 | const instance = new ApiHookClick({ messageId }); 10 | return instance.perform(); 11 | } 12 | 13 | export class ApiHookClick extends Api { 14 | path = "/api/hooks/click"; 15 | 16 | fetchData = { method: "GET" }; 17 | constructor({ messageId }: { messageId?: string } = {}) { 18 | super(); 19 | this.formData = { 20 | url: "http://localhost:3883", 21 | messageId, 22 | }; 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /packages/cli/src/pages/api/__integration__/util/hooks/open.ts: -------------------------------------------------------------------------------- 1 | import { Api } from "../index"; 2 | 3 | interface HookOpenFormData { 4 | messageId?: string; 5 | } 6 | 7 | export async function apiHookOpen({ messageId }: { messageId?: string } = {}) { 8 | const instance = new ApiHookOpen({ messageId: messageId }); 9 | return instance.perform(); 10 | } 11 | 12 | export class ApiHookOpen extends Api { 13 | path = "/api/hooks/open"; 14 | 15 | fetchData = { method: "GET" }; 16 | constructor({ messageId }: { messageId?: string } = {}) { 17 | super(); 18 | this.formData = { 19 | messageId, 20 | }; 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /packages/cli/src/pages/api/__integration__/util/lists.ts: -------------------------------------------------------------------------------- 1 | import { Api } from "./index"; 2 | 3 | interface CreateListFormData { 4 | name: string; 5 | } 6 | 7 | export async function apiGetLists() { 8 | const instance = new ApiGetLists(); 9 | return instance.perform(); 10 | } 11 | 12 | // Return all of an organization's lists 13 | // GET /api/lists 14 | export class ApiGetLists extends Api { 15 | path = "/api/lists"; 16 | method = "GET"; 17 | } 18 | 19 | export async function apiCreateList() { 20 | const instance = new ApiCreateLists(); 21 | return instance.perform(); 22 | } 23 | 24 | // Create a list 25 | // POST /api/lists 26 | export class ApiCreateLists extends Api { 27 | path = "/api/lists"; 28 | method = "POST"; 29 | 30 | formData = { 31 | name: `My list ${Math.random()}`, 32 | }; 33 | } 34 | -------------------------------------------------------------------------------- /packages/cli/src/pages/api/__integration__/util/login.ts: -------------------------------------------------------------------------------- 1 | import { Api } from "."; 2 | import { apiCreateUser } from "./createUser"; 3 | 4 | interface LoginFormData { 5 | email: string; 6 | password: string; 7 | } 8 | 9 | export async function apiLoginAs(email: string, password: string) { 10 | const instance = new ApiLogin(); 11 | instance.formData = { email, password }; 12 | return instance.perform(); 13 | } 14 | 15 | export async function apiLogin() { 16 | const { formData, response: apiCreateUserResponse } = await apiCreateUser(); 17 | expect(apiCreateUserResponse.status).toBe(201); 18 | 19 | const { email, password } = formData; 20 | 21 | const { response: apiLoginResponse } = await apiLoginAs(email, password); 22 | expect(apiLoginResponse.status).toBe(201); 23 | } 24 | 25 | export class ApiLogin extends Api { 26 | path = "/api/session"; 27 | } 28 | -------------------------------------------------------------------------------- /packages/cli/src/pages/api/__integration__/util/logout.ts: -------------------------------------------------------------------------------- 1 | import { Api } from "./index"; 2 | 3 | export function apiLogout() { 4 | const instance = new ApiLogout(); 5 | return instance.perform(); 6 | } 7 | 8 | export class ApiLogout extends Api { 9 | path = "/api/logout"; 10 | } 11 | -------------------------------------------------------------------------------- /packages/cli/src/pages/api/__integration__/util/render.ts: -------------------------------------------------------------------------------- 1 | import { Api } from "./index"; 2 | 3 | interface RenderFormData { 4 | templateName: string; 5 | props?: any; 6 | } 7 | 8 | export function apiRender() { 9 | const instance = new ApiRender(); 10 | return instance.perform(); 11 | } 12 | 13 | export class ApiRender extends Api { 14 | path = "/api/render"; 15 | method = "POST"; 16 | 17 | formData = { 18 | templateName: "Welcome", 19 | props: {}, 20 | }; 21 | } 22 | export class ApiRenderGet extends Api { 23 | path = "/api/render"; 24 | method = "GET"; 25 | 26 | formData = { 27 | templateName: "Welcome", 28 | props: encodeURI(JSON.stringify({})), 29 | }; 30 | } 31 | -------------------------------------------------------------------------------- /packages/cli/src/pages/api/__integration__/util/sendMail.ts: -------------------------------------------------------------------------------- 1 | import { Api } from "./index"; 2 | 3 | interface SendMailFormData { 4 | subject: string; 5 | to: string; 6 | templateName: string; 7 | props: { 8 | includeUnsubscribe: boolean; 9 | }; 10 | } 11 | 12 | export async function apiSendMail(apiKey: string | undefined, formData?: any) { 13 | const instance = new ApiSendMail(apiKey); 14 | if (formData) instance.formData = formData; 15 | return await instance.perform(); 16 | } 17 | 18 | export class ApiSendMail extends Api { 19 | static defaultFormData: SendMailFormData = { 20 | subject: "hello", 21 | to: "alex.farrill@gmail.com", 22 | templateName: "Welcome", 23 | props: { includeUnsubscribe: true }, 24 | }; 25 | 26 | path = "/api/sendMail"; 27 | 28 | constructor(apiKey?: string) { 29 | super(); 30 | this.fetchData = JSON.parse(JSON.stringify(Api.defaultFetchData)); 31 | if (apiKey) this.fetchData.headers["X-API-Key"] = apiKey; 32 | } 33 | 34 | formData = { ...ApiSendMail.defaultFormData }; 35 | } 36 | -------------------------------------------------------------------------------- /packages/cli/src/pages/api/__integration__/util/truncateCliTables.ts: -------------------------------------------------------------------------------- 1 | import cliPrisma from "../../../../../prisma"; 2 | 3 | export const truncateCliTables = async (tables: string[]) => { 4 | const joinedTableNames = tables.map((t) => `"${t}"`).join(", "); 5 | 6 | const query = `TRUNCATE ${joinedTableNames} CASCADE;`; 7 | await cliPrisma.$executeRawUnsafe(query); 8 | }; 9 | -------------------------------------------------------------------------------- /packages/cli/src/pages/api/__integration__/util/unsubscribe.ts: -------------------------------------------------------------------------------- 1 | import { Api } from "./index"; 2 | 3 | type UnsubscribeFormData = { 4 | data: { 5 | [memberId: string]: { 6 | status: "subscribed" | "unsubscribed"; 7 | }; 8 | }; 9 | }; 10 | 11 | export async function apiUnsubscribe( 12 | memberId: string, 13 | formData: UnsubscribeFormData 14 | ) { 15 | const instance = new ApiUnsubscribe(); 16 | instance.path = `/api/unsubscribe/${memberId}`; 17 | instance.formData = formData; 18 | return await instance.perform(); 19 | } 20 | 21 | export class ApiUnsubscribe extends Api { 22 | method = "PATCH"; 23 | } 24 | -------------------------------------------------------------------------------- /packages/cli/src/pages/api/__test__/session.test.ts: -------------------------------------------------------------------------------- 1 | import { NextApiRequest, NextApiResponse } from "next"; 2 | import handler from "../session"; 3 | 4 | function mockRequestResponse(method: string) { 5 | const { req, res } = { 6 | req: { method } as NextApiRequest, 7 | res: {} as unknown as NextApiResponse, 8 | }; 9 | res.json = jest.fn(); 10 | res.end = jest.fn(); 11 | res.status = jest.fn(() => res); 12 | req.headers = { "Content-Type": "application/json" }; 13 | return { req, res }; 14 | } 15 | 16 | describe("login", () => { 17 | const MAILING_SESSION_PASSWORD_OG = process.env.MAILING_SESSION_PASSWORD; 18 | 19 | beforeEach(() => { 20 | delete process.env.MAILING_SESSION_PASSWORD; 21 | }); 22 | 23 | afterEach(() => { 24 | process.env.MAILING_SESSION_PASSWORD = MAILING_SESSION_PASSWORD_OG; 25 | }); 26 | 27 | it("should 404 if MAILING_SESSION_PASSWORD is not set", async () => { 28 | const { req, res } = mockRequestResponse("GET"); 29 | await handler(req, res); 30 | expect(res.status).toHaveBeenCalledWith(405); 31 | }); 32 | }); 33 | -------------------------------------------------------------------------------- /packages/cli/src/pages/api/apiKeys/index.ts: -------------------------------------------------------------------------------- 1 | import { withSessionAPIRoute } from "../../../util/session"; 2 | import { NextApiRequest, NextApiResponse } from "next"; 3 | import prisma from "../../../../prisma"; 4 | 5 | type ApiKey = { 6 | id: string; 7 | }; 8 | 9 | type Data = { 10 | error?: string; 11 | apiKey?: ApiKey; 12 | apiKeys?: ApiKey[]; 13 | }; 14 | 15 | const ApiKeys = withSessionAPIRoute(async function ( 16 | req: NextApiRequest, 17 | res: NextApiResponse 18 | ) { 19 | const user = req.session.user; 20 | 21 | // require login 22 | if (!user) { 23 | return res.status(404).end(); 24 | } 25 | 26 | if ("POST" === req.method) { 27 | // create a new API key and return it as JSON 28 | 29 | const apiKey = await prisma.apiKey.create({ 30 | data: { 31 | organizationId: user.organizationId, 32 | }, 33 | }); 34 | 35 | res.status(201).json({ 36 | apiKey, 37 | }); 38 | } else if ("GET" === req.method) { 39 | // list APIKeys 40 | 41 | const apiKeys = await prisma.apiKey.findMany({ 42 | where: { organizationId: user.organizationId, active: true }, 43 | select: { id: true }, 44 | }); 45 | 46 | res.status(200).json({ 47 | apiKeys, 48 | }); 49 | } else { 50 | return res.status(404).end(); 51 | } 52 | }); 53 | 54 | export default ApiKeys; 55 | -------------------------------------------------------------------------------- /packages/cli/src/pages/api/hooks/__integration__/open.test.ts: -------------------------------------------------------------------------------- 1 | import { apiHookOpen } from "../../__integration__/util/hooks/open"; 2 | import prisma from "../../../../../prisma"; 3 | 4 | describe("api/hooks/open", () => { 5 | it("increments openCount", async () => { 6 | const message = await prisma.message.create({ 7 | data: { 8 | to: "user@mailing.dev", 9 | from: "from@mailing.dev", 10 | subject: "Test Email", 11 | templateName: "Test", 12 | }, 13 | }); 14 | const { response } = await apiHookOpen({ messageId: message.id }); 15 | expect(response.status).toEqual(200); 16 | 17 | const updatedMessage = await prisma.message.findUnique({ 18 | where: { id: message.id }, 19 | }); 20 | expect(updatedMessage?.openCount).toEqual(1); 21 | }); 22 | 23 | it("handles message missing", async () => { 24 | const { response } = await apiHookOpen(); 25 | expect(response.status).toEqual(200); 26 | }); 27 | }); 28 | -------------------------------------------------------------------------------- /packages/cli/src/pages/api/hooks/open.ts: -------------------------------------------------------------------------------- 1 | import { NextApiRequest, NextApiResponse } from "next"; 2 | import Analytics from "../../../util/analytics"; 3 | import prisma from "../../../../prisma"; 4 | import { debug } from "../../../util/serverLogger"; 5 | 6 | export default async function handler( 7 | req: NextApiRequest, 8 | res: NextApiResponse 9 | ) { 10 | if (req.method !== "GET") { 11 | return res.status(405).json({ error: "Method not allowed" }); 12 | } 13 | const { messageId } = req.query; 14 | 15 | if (typeof messageId === "string") { 16 | await Analytics.track({ 17 | event: "email.open", 18 | properties: { messageId: messageId }, 19 | }); 20 | 21 | const message = await prisma.message.findUnique({ 22 | where: { id: messageId }, 23 | }); 24 | 25 | try { 26 | await prisma.message.update({ 27 | where: { id: messageId }, 28 | data: { 29 | openCount: { increment: 1 }, 30 | openedAt: message?.openedAt ? undefined : new Date(), 31 | }, 32 | }); 33 | } catch (err) { 34 | debug(err); 35 | } 36 | } 37 | 38 | res.status(200).end(); 39 | } 40 | -------------------------------------------------------------------------------- /packages/cli/src/pages/api/logout.tsx: -------------------------------------------------------------------------------- 1 | import { withSessionAPIRoute } from "../../util/session"; 2 | import { NextApiRequest, NextApiResponse } from "next"; 3 | 4 | type Data = { 5 | ok?: boolean; 6 | }; 7 | 8 | const Logout = withSessionAPIRoute(async function ( 9 | req: NextApiRequest, 10 | res: NextApiResponse 11 | ) { 12 | req.session.destroy(); 13 | res.send({ ok: true }); 14 | }); 15 | 16 | export default Logout; 17 | -------------------------------------------------------------------------------- /packages/cli/src/pages/api/previews/__test__/index.test.tsx: -------------------------------------------------------------------------------- 1 | import { NextApiRequest, NextApiResponse } from "next"; 2 | import { createMocks } from "node-mocks-http"; 3 | import index from ".."; 4 | 5 | describe("index", () => { 6 | it("should return previews", async () => { 7 | const { req, res } = createMocks({ 8 | method: "GET", 9 | }); 10 | 11 | await index( 12 | req as unknown as NextApiRequest, 13 | res as unknown as NextApiResponse 14 | ); 15 | 16 | expect(res.statusCode).toBe(200); 17 | const json = res._getJSONData(); 18 | expect(json).toMatchSnapshot(); 19 | }); 20 | 21 | it("should return template subject if it exists", async () => { 22 | const { req, res } = createMocks({ 23 | method: "GET", 24 | }); 25 | 26 | await index( 27 | req as unknown as NextApiRequest, 28 | res as unknown as NextApiResponse 29 | ); 30 | 31 | expect(res.statusCode).toBe(200); 32 | const json = res._getJSONData(); 33 | expect( 34 | json["previewInfo"]["/previews/AccountCreated/accountCreated"] 35 | ).toMatchSnapshot(); 36 | }); 37 | }); 38 | -------------------------------------------------------------------------------- /packages/cli/src/pages/api/session.ts: -------------------------------------------------------------------------------- 1 | import type { NextApiRequest, NextApiResponse } from "next"; 2 | import { withSessionAPIRoute } from "../../util/session"; 3 | import { validate } from "email-validator"; 4 | import { compare } from "bcrypt"; 5 | import prisma from "../../../prisma"; 6 | 7 | type Data = { 8 | error?: string; 9 | }; 10 | 11 | const handler = withSessionAPIRoute(async function ( 12 | req: NextApiRequest, 13 | res: NextApiResponse 14 | ) { 15 | if (req.method !== "POST") 16 | return res.status(405).json({ error: "Method not allowed" }); 17 | 18 | const email = req.body.email; 19 | const plainTextPassword = req.body.password; 20 | 21 | if (!validate(email)) 22 | return res.status(400).json({ error: "email is invalid" }); 23 | 24 | const user = await prisma.user.findFirst({ 25 | where: { email }, 26 | }); 27 | 28 | if (!user) 29 | return res.status(400).json({ error: "No user exists with that email." }); 30 | 31 | const authenticated = await compare(plainTextPassword, user.password); 32 | 33 | if (authenticated) { 34 | req.session.user = user; 35 | await req.session.save(); 36 | 37 | res.status(201).end(); 38 | } else { 39 | res.status(401).json({ error: "invalid password" }); 40 | } 41 | }); 42 | 43 | export default handler; 44 | -------------------------------------------------------------------------------- /packages/cli/src/pages/api/unsubscribe/[memberId].ts: -------------------------------------------------------------------------------- 1 | import { NextApiRequest, NextApiResponse } from "next"; 2 | import prisma from "../../../../prisma"; 3 | import { LIST_MEMBER_STATUSES } from "../../../util/api/validateMemberStatusInList"; 4 | 5 | export default async function unsubscribeMember( 6 | req: NextApiRequest, 7 | res: NextApiResponse 8 | ) { 9 | if ("PATCH" === req.method) { 10 | const data = req.body.data; 11 | 12 | // validate statuses are in the list 13 | const statuses = Object.keys(data).reduce((acc: string[], key) => { 14 | acc.push(data[key].status); 15 | return acc; 16 | }, []); 17 | 18 | if ( 19 | statuses.find((status: any) => !LIST_MEMBER_STATUSES.includes(status)) 20 | ) { 21 | return res 22 | .status(422) 23 | .json("status should be one of: " + LIST_MEMBER_STATUSES.join(", ")); 24 | } 25 | 26 | for (const key in data) { 27 | const memberId = key; 28 | const status = data[key].status; 29 | await prisma.member.update({ where: { id: memberId }, data: { status } }); 30 | } 31 | 32 | return res.status(200).end(); 33 | } else { 34 | return res.status(404).end(); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /packages/cli/src/pages/index.tsx: -------------------------------------------------------------------------------- 1 | import { GetServerSideProps, NextPage } from "next"; 2 | import { previewTree } from "../util/moduleManifestUtil"; 3 | 4 | export const getServerSideProps: GetServerSideProps = async () => { 5 | let destination = "/previews"; 6 | const tree = previewTree(); 7 | const firstPreview = tree[0]; 8 | if (firstPreview && firstPreview[1]?.length) { 9 | destination = `/previews/${firstPreview[0]}/${firstPreview[1][0]}`; 10 | } 11 | 12 | return { 13 | redirect: { 14 | destination, 15 | permanent: false, 16 | }, 17 | }; 18 | }; 19 | 20 | const Home: NextPage = () => { 21 | return null; 22 | }; 23 | 24 | export default Home; 25 | -------------------------------------------------------------------------------- /packages/cli/src/pages/intercepts/[interceptId].tsx: -------------------------------------------------------------------------------- 1 | import { useRouter } from "next/router"; 2 | import { useCallback, useEffect, useState } from "react"; 3 | import Intercept from "../../components/Intercept"; 4 | 5 | const ShowIntercept = () => { 6 | const { query } = useRouter(); 7 | const [data, setData] = useState({ id: "-1", html: "" }); 8 | const fetchData = useCallback(async () => { 9 | const res = await fetch(`/intercepts/${query.interceptId}.json`); 10 | setData(await res.json()); 11 | }, [query.interceptId]); 12 | useEffect(() => { 13 | if (query.interceptId) void fetchData(); 14 | }, [query.interceptId, fetchData]); 15 | 16 | return ; 17 | }; 18 | 19 | export default ShowIntercept; 20 | -------------------------------------------------------------------------------- /packages/cli/src/pages/intercepts/mock.tsx: -------------------------------------------------------------------------------- 1 | import Intercept from "../../components/Intercept"; 2 | 3 | const ShowIntercept = () => { 4 | const data = { 5 | id: "mock", 6 | html: "

Title

hope it's not too strict", 7 | to: "peter s. ", 8 | from: { name: "peter", address: "peter+sendgrid@campsh.com" }, 9 | subject: "A test email", 10 | }; 11 | 12 | return ; 13 | }; 14 | 15 | export default ShowIntercept; 16 | -------------------------------------------------------------------------------- /packages/cli/src/preview/controllers/application.ts: -------------------------------------------------------------------------------- 1 | import { ServerResponse } from "http"; 2 | 3 | export function renderNotFound(res: ServerResponse) { 4 | res.writeHead(404); 5 | res.end("Not found"); 6 | } 7 | -------------------------------------------------------------------------------- /packages/cli/src/session.d.ts: -------------------------------------------------------------------------------- 1 | import { User } from "../prisma/generated/client"; 2 | 3 | declare module "iron-session" { 4 | interface IronSessionData { 5 | user?: User; 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /packages/cli/src/styles/globals.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | body { 6 | font-family: neue-haas-unica, sans-serif; 7 | font-weight: 400; 8 | color: #e4ebfa; 9 | background-color: #111; 10 | } 11 | 12 | .mono { 13 | font-family: Menlo, Monaco, Lucida Console, Liberation Mono, DejaVu Sans Mono, 14 | Bitstream Vera Sans Mono, Courier New, monospace; 15 | } 16 | 17 | button:active, 18 | a:active { 19 | transform: translateY(1px); 20 | } 21 | 22 | /* 23 | * This fixes an intermittent bug we were seeing with the header send icon blocking the button click. 24 | * I don't quite understand what the bug is so I'm applying this globally in case it happens elsewhere too. 25 | */ 26 | svg { 27 | pointer-events: none; 28 | } 29 | -------------------------------------------------------------------------------- /packages/cli/src/util/__test__/__snapshots__/moduleManifestUtil.test.ts.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`moduleManifestUtil the original module manifest has a tree of preview components 1`] = ` 4 | [ 5 | [ 6 | "AccountCreated", 7 | [ 8 | "accountCreated", 9 | ], 10 | ], 11 | [ 12 | "NewSignIn", 13 | [ 14 | "newSignIn", 15 | ], 16 | ], 17 | [ 18 | "Reservation", 19 | [ 20 | "reservationWithError", 21 | "reservationConfirmed", 22 | "reservationChanged", 23 | ], 24 | ], 25 | [ 26 | "ResetPassword", 27 | [ 28 | "resetPassword", 29 | ], 30 | ], 31 | ] 32 | `; 33 | -------------------------------------------------------------------------------- /packages/cli/src/util/__test__/moduleManifestUtil.test.ts: -------------------------------------------------------------------------------- 1 | jest.mock("../../moduleManifest"); 2 | 3 | import moduleManifest from "../../moduleManifest"; 4 | import { previewTree } from "../moduleManifestUtil"; 5 | 6 | describe("moduleManifestUtil", () => { 7 | const ogPreviews = moduleManifest.previews; 8 | 9 | afterEach(() => { 10 | moduleManifest.previews = ogPreviews; 11 | }); 12 | 13 | it("the original module manifest has a tree of preview components", () => { 14 | const previews = previewTree(); 15 | expect(previews).toMatchSnapshot(); 16 | }); 17 | 18 | it("does not error on empty preview files", () => { 19 | moduleManifest.previews = { AccountCreated: {} } as any; 20 | expect(previewTree()[0]).toEqual(["AccountCreated", []]); 21 | 22 | moduleManifest.previews = { AccountCreated: false } as any; 23 | expect(previewTree()[0]).toEqual(["AccountCreated", []]); 24 | 25 | moduleManifest.previews = { AccountCreated: { default: jest.fn() } } as any; 26 | expect(previewTree()[0]).toEqual(["AccountCreated", []]); 27 | 28 | moduleManifest.previews = { AccountCreated: { __esModule: true } } as any; 29 | expect(previewTree()[0]).toEqual(["AccountCreated", []]); 30 | }); 31 | }); 32 | -------------------------------------------------------------------------------- /packages/cli/src/util/__test__/renderTemplate.test.ts: -------------------------------------------------------------------------------- 1 | import renderTemplate from "../renderTemplate"; 2 | 3 | describe("renderTemplate", () => { 4 | it("throws an error if template not found", () => { 5 | const { error, mjmlErrors, html } = renderTemplate("test", { 6 | name: "test", 7 | }); 8 | expect(html).toBeUndefined(); 9 | expect(mjmlErrors).toBeUndefined(); 10 | expect(error).toMatch(/Template test not found in list of templates/); 11 | }); 12 | 13 | it("returns rendered html", () => { 14 | const { error, mjmlErrors, html } = renderTemplate("AccountCreated", { 15 | name: "Test User", 16 | }); 17 | expect(mjmlErrors).toEqual([]); 18 | expect(error).toBeUndefined(); 19 | expect(html).not.toBeUndefined(); 20 | expect(html).toMatchSnapshot(); 21 | }); 22 | }); 23 | -------------------------------------------------------------------------------- /packages/cli/src/util/__test__/testUtils.ts: -------------------------------------------------------------------------------- 1 | import type { ReactElement } from "react"; 2 | import { render } from "@testing-library/react"; 3 | import userEvent from "@testing-library/user-event"; 4 | import { UserEvent } from "@testing-library/user-event/dist/types/setup/setup"; 5 | 6 | export function setup( 7 | jsx: ReactElement 8 | ): ReturnType & { user: UserEvent } { 9 | return { 10 | user: userEvent.setup(), 11 | ...render(jsx), 12 | }; 13 | } 14 | 15 | export function triggerKey(key: string) { 16 | window.document.dispatchEvent(new KeyboardEvent("keydown", { key })); 17 | } 18 | -------------------------------------------------------------------------------- /packages/cli/src/util/analytics/index.ts: -------------------------------------------------------------------------------- 1 | import { Axiom, Posthog } from "./providers"; 2 | 3 | class Analytics { 4 | providers: IAnalyticsProvider[]; 5 | 6 | constructor() { 7 | let axiom, posthog; 8 | if (process.env.AXIOM_API_TOKEN && process.env.AXIOM_DATASET_NAME) { 9 | axiom = new Axiom( 10 | process.env.AXIOM_API_TOKEN, 11 | process.env.AXIOM_DATASET_NAME 12 | ); 13 | } 14 | if (process.env.POSTHOG_API_TOKEN) { 15 | posthog = new Posthog(process.env.POSTHOG_API_TOKEN); 16 | } 17 | 18 | this.providers = [axiom, posthog].filter((p) => p) as IAnalyticsProvider[]; 19 | } 20 | 21 | track(event: AnalyticsEvent) { 22 | const trackCalls = this.providers.map((provider) => { 23 | return provider.track(event); 24 | }); 25 | 26 | return Promise.all(trackCalls); 27 | } 28 | 29 | trackMany(events: AnalyticsEvent[]) { 30 | const trackManyCalls = this.providers.map((provider) => { 31 | return provider.trackMany(events); 32 | }); 33 | 34 | return Promise.all(trackManyCalls); 35 | } 36 | } 37 | 38 | export { Analytics }; 39 | 40 | // default export a singleton 41 | export default new Analytics(); 42 | -------------------------------------------------------------------------------- /packages/cli/src/util/analytics/providers/AnalyticsProvider.d.ts: -------------------------------------------------------------------------------- 1 | type AnalyticsEvent = { event: string; properties?: Record }; 2 | 3 | interface IAnalyticsProvider { 4 | track: (event: AnalyticsEvent) => Promise; 5 | trackMany: (events: Array) => Promise; 6 | } 7 | -------------------------------------------------------------------------------- /packages/cli/src/util/analytics/providers/Posthog.ts: -------------------------------------------------------------------------------- 1 | import fetch from "node-fetch"; 2 | 3 | class Posthog implements IAnalyticsProvider { 4 | static baseUrl = "https://app.posthog.com"; 5 | apiToken: string; 6 | 7 | constructor(apiToken: string) { 8 | this.apiToken = apiToken; 9 | } 10 | 11 | track(event: AnalyticsEvent) { 12 | return this.#capture(event); 13 | } 14 | 15 | trackMany(events: AnalyticsEvent[]) { 16 | return this.#batch(events); 17 | } 18 | 19 | #capture(event: AnalyticsEvent) { 20 | return fetch(Posthog.baseUrl + "/capture/", { 21 | method: "POST", 22 | headers: { 23 | "Content-Type": "application/json", 24 | }, 25 | body: JSON.stringify({ 26 | api_key: this.apiToken, 27 | ...event, 28 | }), 29 | }); 30 | } 31 | 32 | #batch(events: AnalyticsEvent[]) { 33 | return fetch(Posthog.baseUrl + "/batch/", { 34 | method: "POST", 35 | headers: { 36 | "Content-Type": "application/json", 37 | }, 38 | body: JSON.stringify({ 39 | api_key: this.apiToken, 40 | batch: events, 41 | }), 42 | }); 43 | } 44 | } 45 | 46 | export default Posthog; 47 | -------------------------------------------------------------------------------- /packages/cli/src/util/analytics/providers/index.ts: -------------------------------------------------------------------------------- 1 | import Axiom from "./Axiom"; 2 | import Posthog from "./Posthog"; 3 | 4 | export { Axiom, Posthog }; 5 | -------------------------------------------------------------------------------- /packages/cli/src/util/api/validateMemberStatusInList.ts: -------------------------------------------------------------------------------- 1 | import { ValidatedRequestOrError } from "./validate"; 2 | 3 | export const LIST_MEMBER_STATUSES = ["subscribed", "unsubscribed"] as const; 4 | 5 | export type ListMemberStatus = typeof LIST_MEMBER_STATUSES[number]; 6 | 7 | export function validateMemberStatusInList( 8 | status: ListMemberStatus 9 | ): ValidatedRequestOrError { 10 | return LIST_MEMBER_STATUSES.includes(status) 11 | ? { hasError: false, validated: {} } 12 | : { 13 | hasError: true, 14 | status: 422, 15 | error: `expected status to be one of: ${LIST_MEMBER_STATUSES.join( 16 | ", " 17 | )}`, 18 | }; 19 | } 20 | -------------------------------------------------------------------------------- /packages/cli/src/util/buildHandler.ts: -------------------------------------------------------------------------------- 1 | import { log } from "./serverLogger"; 2 | import { existsSync } from "fs-extra"; 3 | import { setConfig, writeDefaultConfigFile } from "./config"; 4 | 5 | export function buildHandler(handler: (argv: any) => Promise) { 6 | return async (argv: any) => { 7 | if (!existsSync("./package.json")) { 8 | log("No package.json found. Please run from the project root."); 9 | return; 10 | } 11 | 12 | // check for presence of options that apply to every command 13 | if (!argv.emailsDir) throw new Error("emailsDir option is not set"); 14 | 15 | setConfig({ 16 | emailsDir: argv.emailsDir, 17 | port: argv.port, 18 | quiet: argv.quiet, 19 | }); 20 | 21 | writeDefaultConfigFile(); 22 | 23 | await handler(argv); 24 | }; 25 | } 26 | -------------------------------------------------------------------------------- /packages/cli/src/util/config/__test__/__snapshots__/config.test.ts.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`writeDefaultConfigFile writes mailing.config.json if it doesn't exist 1`] = ` 4 | [MockFunction] { 5 | "calls": [ 6 | [ 7 | "added mailing.config.json in your project with the following contents: 8 | { 9 | "typescript": true, 10 | "emailsDir": "./emails", 11 | "outDir": "./previews_html" 12 | } 13 | ", 14 | ], 15 | ], 16 | "results": [ 17 | { 18 | "type": "return", 19 | "value": undefined, 20 | }, 21 | ], 22 | } 23 | `; 24 | -------------------------------------------------------------------------------- /packages/cli/src/util/generators.ts: -------------------------------------------------------------------------------- 1 | import { copy } from "fs-extra"; 2 | import { resolve } from "path"; 3 | import tree from "tree-node-cli"; 4 | import { log } from "./serverLogger"; 5 | 6 | export async function generateEmailsDirectory({ 7 | emailsDir, 8 | isTypescript, 9 | }: { 10 | emailsDir: string; 11 | isTypescript: boolean; 12 | }) { 13 | const srcDir = 14 | process.env.MM_DEV || process.env.NODE_ENV === "test" 15 | ? __dirname + "/.." 16 | : __dirname + "/../src"; 17 | 18 | // copy the emails dir template in! 19 | const srcEmails = isTypescript ? "emails" : "emails-js"; 20 | await copy(resolve(srcDir, srcEmails), emailsDir, { overwrite: false }); 21 | 22 | const fileTree = tree(emailsDir, { 23 | exclude: [/node_modules|\.mailing|yarn\.lock|yalc\.lock/], 24 | }); 25 | log(`generated your emails dir at ${emailsDir}:\n${fileTree}`); 26 | } 27 | -------------------------------------------------------------------------------- /packages/cli/src/util/jsonStringifyError.ts: -------------------------------------------------------------------------------- 1 | export function jsonStringifyError(err: any) { 2 | if (err instanceof Error) { 3 | return JSON.stringify(err, Object.getOwnPropertyNames(err)); 4 | } else { 5 | console.error("jsonStringifyError called with non-Error", err); 6 | return JSON.stringify(err); 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /packages/cli/src/util/lists.ts: -------------------------------------------------------------------------------- 1 | import prisma from "../../prisma"; 2 | import type { Organization } from "../../prisma/generated/client"; 3 | 4 | // find or create the default list 5 | export async function findOrCreateDefaultList(org?: Organization | null) { 6 | let defaultList = await prisma.list.findFirst({ 7 | where: { 8 | isDefault: true, 9 | }, 10 | }); 11 | 12 | if (!defaultList) { 13 | const organization = org || (await prisma.organization.findFirst()); 14 | if (!organization) throw new Error("No organization found"); 15 | 16 | defaultList = await prisma.list.create({ 17 | data: { 18 | name: "default", 19 | displayName: "Default", 20 | organizationId: organization.id, 21 | isDefault: true, 22 | }, 23 | }); 24 | } 25 | 26 | return defaultList; 27 | } 28 | -------------------------------------------------------------------------------- /packages/cli/src/util/paths.ts: -------------------------------------------------------------------------------- 1 | import { existsSync, readJSONSync } from "fs-extra"; 2 | import { resolve } from "path"; 3 | import { error } from "./serverLogger"; 4 | 5 | // appends /previews to emailsDir string if that directory exists 6 | // otherwise, return null 7 | export function getPreviewsDirectory(emailsDir: string): string | null { 8 | const previewsDirectory = resolve(emailsDir, "previews"); 9 | 10 | return existsSync(previewsDirectory) ? previewsDirectory : null; 11 | } 12 | 13 | export function readPackageJSON() { 14 | return readJSONverbose("./package.json"); 15 | } 16 | 17 | export function readJSONverbose(filename: string) { 18 | try { 19 | return readJSONSync(filename); 20 | } catch (err) { 21 | error(`expected ${filename} to exist and be valid JSON`); 22 | throw err; 23 | } 24 | } 25 | 26 | export function getMailingAPIBaseURL() { 27 | if (process.env.MM_DEV) { 28 | return `http://localhost:3000`; 29 | } else { 30 | return "https://www.mailing.run"; 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /packages/cli/src/util/renderTemplate.ts: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { render } from "mailing-core"; 3 | import { templates } from "../moduleManifest"; 4 | import { getTemplateModule } from './moduleManifestUtil' 5 | 6 | type renderTemplateResult = { 7 | error?: string; 8 | mjmlErrors?: MjmlError[]; 9 | html?: string; 10 | subject?: string; 11 | }; 12 | 13 | const renderTemplate = ( 14 | templateName: string, 15 | props: { [key: string]: any } 16 | ): renderTemplateResult => { 17 | const Template = getTemplateModule(templateName) 18 | if (!Template) { 19 | return { 20 | error: `Template ${templateName} not found in list of templates: ${Object.keys( 21 | templates 22 | ).join(", ")}`, 23 | }; 24 | } 25 | 26 | const { html, errors } = render(React.createElement(Template as any, props)); 27 | 28 | let subject 29 | if (typeof Template.subject === "function") { 30 | subject = Template.subject(props); 31 | } else if (typeof Template.subject === "string") { 32 | subject = Template.subject; 33 | } 34 | 35 | return { 36 | html, 37 | subject, 38 | mjmlErrors: errors, 39 | } 40 | }; 41 | 42 | export default renderTemplate; 43 | -------------------------------------------------------------------------------- /packages/cli/src/util/serverLogger.ts: -------------------------------------------------------------------------------- 1 | import chalk from "chalk"; 2 | import { getQuiet } from "./config"; 3 | 4 | const { DEBUG } = process.env; 5 | 6 | const PREFIX = "mailing"; 7 | 8 | export function log(message?: any, ...optionalParams: any[]) { 9 | if (getQuiet() && !DEBUG) return; 10 | console.log(chalk.white(PREFIX), message, ...optionalParams); 11 | } 12 | 13 | export function error(message?: any, ...optionalParams: any[]) { 14 | console.error(chalk.red(PREFIX), message, ...optionalParams); 15 | } 16 | 17 | export function debug(message?: any, ...optionalParams: any[]) { 18 | if (DEBUG) 19 | console.info(chalk.yellowBright(PREFIX), message, ...optionalParams); 20 | } 21 | 22 | export function logPlain(message?: any, ...optionalParams: any[]) { 23 | if (getQuiet() && !DEBUG) return; 24 | console.log(message, ...optionalParams); 25 | } 26 | -------------------------------------------------------------------------------- /packages/cli/src/util/tailwind.ts: -------------------------------------------------------------------------------- 1 | import resolveConfig from "tailwindcss/resolveConfig"; 2 | import tailwindConfig from "../../tailwind.config.js"; 3 | 4 | const fullConfig = resolveConfig(tailwindConfig); 5 | 6 | export const colors = fullConfig.theme?.colors as unknown as Record< 7 | string, 8 | string 9 | >; 10 | export default fullConfig; 11 | -------------------------------------------------------------------------------- /packages/cli/src/util/validate/validateMethod.ts: -------------------------------------------------------------------------------- 1 | import { NextApiRequest, NextApiResponse } from "next"; 2 | 3 | type HttpMethod = "GET" | "POST" | "PUT" | "PATCH" | "DELETE"; 4 | 5 | export function validateMethod( 6 | validMethods: HttpMethod[], 7 | req: NextApiRequest, 8 | res: NextApiResponse 9 | ): boolean { 10 | if (0 === validMethods.length) 11 | throw new Error("must include at least one valid method"); 12 | 13 | if (validMethods.includes(req.method as HttpMethod)) { 14 | return true; 15 | } else { 16 | res.status(405).end(); 17 | return false; 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /packages/cli/src/util/validate/validateTemplate.ts: -------------------------------------------------------------------------------- 1 | import { NextApiResponse } from "next"; 2 | import { templates } from "../../moduleManifest"; 3 | import { getTemplateModule } from "../moduleManifestUtil"; 4 | 5 | export function errorTemplateNameMustBeSpecified(res: NextApiResponse) { 6 | return res.status(422).json({ error: "templateName must be specified" }); 7 | } 8 | 9 | export function errorTemplateNotFoundInListOfTemplates( 10 | templateName: string, 11 | res: NextApiResponse 12 | ) { 13 | return res.status(422).json({ 14 | error: `Template ${templateName} not found in list of templates: ${Object.keys( 15 | templates 16 | ).join(", ")}`, 17 | }); 18 | } 19 | 20 | export function validateTemplate( 21 | templateName: string, 22 | res: NextApiResponse 23 | ): boolean { 24 | if (typeof templateName !== "string") { 25 | errorTemplateNameMustBeSpecified(res); 26 | 27 | return false; 28 | } else { 29 | const template = getTemplateModule(templateName); 30 | if (!template) { 31 | errorTemplateNotFoundInListOfTemplates(templateName, res); 32 | 33 | return false; 34 | } 35 | 36 | return true; 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /packages/cli/src/util/wrapError.ts: -------------------------------------------------------------------------------- 1 | // useful if you want to add some context to an error, e.g. if the same error could have been thrown in multiple places 2 | export function wrapError(e: any, name: string) { 3 | e.name = `${name} (originally '${e.name}')`; 4 | return e; 5 | } 6 | -------------------------------------------------------------------------------- /packages/cli/tailwind.config.js: -------------------------------------------------------------------------------- 1 | const path = require("path"); 2 | const theme = require("./theme"); 3 | 4 | /** @type {import('tailwindcss').Config} */ 5 | module.exports = { 6 | content: [ 7 | path.join(__dirname, "./src/pages/**/*.{js,ts,jsx,tsx}"), 8 | path.join(__dirname, "./src/components/**/*.{js,ts,jsx,tsx}"), 9 | ], 10 | theme, 11 | plugins: [require("@tailwindcss/line-clamp")], 12 | }; 13 | -------------------------------------------------------------------------------- /packages/core/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "mailing-core", 3 | "version": "1.0.2", 4 | "main": "dist/mailing-core.cjs.js", 5 | "license": "MIT", 6 | "description": "Fun email development environment (core library only)", 7 | "homepage": "https://github.com/sofn-xyz/mailing#readme", 8 | "repository": { 9 | "type": "git", 10 | "url": "git+https://github.com/sofn-xyz/mailing.git" 11 | }, 12 | "dependencies": { 13 | "@faire/mjml-react": "^3.1.2", 14 | "chalk": "^4.1.2", 15 | "fs-extra": "^10.1.0", 16 | "mjml": "^4.12.0", 17 | "node-fetch": "^2.6.7", 18 | "node-html-parser": "^6.1.1", 19 | "open": "^8.4.0", 20 | "posthog-node": "^2.2.3" 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /packages/core/src/__test__/render.test.tsx: -------------------------------------------------------------------------------- 1 | import { render } from "../index"; 2 | import { Mjml, MjmlBody, MjmlRaw } from "@faire/mjml-react"; 3 | 4 | describe("index render", () => { 5 | it("takes an MJML react component and renders HTML", () => { 6 | const { html, errors } = render( 7 | 8 | 9 | Hello 10 | 11 | 12 | ); 13 | expect(html).toContain(""); 14 | expect(html).toContain("Hello"); 15 | expect(errors.length).toBe(0); 16 | }); 17 | }); 18 | -------------------------------------------------------------------------------- /packages/core/src/__test__/testSetup.ts: -------------------------------------------------------------------------------- 1 | beforeAll(() => { 2 | jest.spyOn(console, "log").mockImplementation(jest.fn()); 3 | }); 4 | afterEach(() => { 5 | jest.resetAllMocks(); 6 | }); 7 | -------------------------------------------------------------------------------- /packages/core/src/const/mailingCoreVersion.ts: -------------------------------------------------------------------------------- 1 | export const MAILING_CORE_VERSION = "1.0.1"; 2 | -------------------------------------------------------------------------------- /packages/core/src/mjml-browser.d.ts: -------------------------------------------------------------------------------- 1 | declare module "mjml-browser" { 2 | const transform: ( 3 | vml: string, 4 | options?: { 5 | beautify?: boolean; 6 | minify?: boolean; 7 | keepComments?: boolean; 8 | validationLevel: "strict" | "soft" | "skip"; 9 | } 10 | ) => { 11 | json: MjmlBlockItem; 12 | html: string; 13 | errors: string[]; 14 | }; 15 | export default transform; 16 | } 17 | 18 | interface MjmlBlockItem { 19 | file: string; 20 | absoluteFilePath: string; 21 | line: number; 22 | includedIn: any[]; 23 | tagName: string; 24 | children: IChildrenItem[]; 25 | attributes: IAttributes; 26 | content?: string; 27 | } 28 | interface IChildrenItem { 29 | file?: string; 30 | absoluteFilePath?: string; 31 | line: number; 32 | includedIn: any[]; 33 | tagName: string; 34 | children?: IChildrenItem[]; 35 | attributes: IAttributes; 36 | content?: string; 37 | inline?: "inline"; 38 | } 39 | interface IAttributes { 40 | [key: string]: any; 41 | } 42 | -------------------------------------------------------------------------------- /packages/core/src/mjml.ts: -------------------------------------------------------------------------------- 1 | import { JSXElementConstructor, ReactElement } from "react"; 2 | import { renderToMjml } from "@faire/mjml-react/utils/renderToMjml"; 3 | import mjml2html from "mjml"; 4 | 5 | export function render( 6 | component: ReactElement>, 7 | options?: { 8 | processHtml?: (html: string) => string; 9 | } 10 | ) { 11 | const { html, errors } = mjml2html(renderToMjml(component), { 12 | validationLevel: "soft", 13 | }); 14 | 15 | return { 16 | html: options?.processHtml?.(html) || html, 17 | errors, 18 | }; 19 | } 20 | -------------------------------------------------------------------------------- /packages/core/src/util/__test__/__snapshots__/instrumentHtml.test.ts.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`instrumentHtml should instrument html 1`] = ` 4 | " 5 | 6 | 7 | Test 8 | 9 | 10 | Google 11 | Yahoo 12 | 13 | 14 | " 15 | `; 16 | -------------------------------------------------------------------------------- /packages/core/src/util/__test__/instrumentHtml.test.ts: -------------------------------------------------------------------------------- 1 | import instrumentHtml from "../instrumentHtml"; 2 | 3 | describe("instrumentHtml", () => { 4 | const messageId = "123"; 5 | const apiUrl = "https://api.com"; 6 | 7 | it("should instrument html", () => { 8 | const html = ` 9 | 10 | 11 | Test 12 | 13 | 14 | Google 15 | Yahoo 16 | 17 | 18 | `; 19 | 20 | const result = instrumentHtml({ html, messageId, apiUrl }); 21 | 22 | expect(result).toContain( 23 | 'href="https://api.com/api/hooks/click?messageId=123&url=https%3A%2F%2Fgoogle.com"' 24 | ); 25 | expect(result).not.toContain('href="https://google.com"'); 26 | expect(result).toContain( 27 | '' 28 | ); 29 | 30 | expect(result).toMatchSnapshot(); 31 | }); 32 | 33 | it("throws error if no body found", () => { 34 | const html = ` 35 | 36 | 37 | Test 38 | 39 | 40 | `; 41 | 42 | expect(() => instrumentHtml({ html, messageId, apiUrl })).toThrow(); 43 | }); 44 | }); 45 | -------------------------------------------------------------------------------- /packages/core/src/util/instrumentHtml.ts: -------------------------------------------------------------------------------- 1 | import { parse } from "node-html-parser"; 2 | import { error } from "./serverLogger"; 3 | 4 | export default function instrumentHtml({ 5 | html, 6 | messageId, 7 | apiUrl, 8 | }: { 9 | html: string; 10 | messageId: string; 11 | apiUrl: string; 12 | }) { 13 | try { 14 | // find the a, get href, calculate new url, replace href 15 | const root = parse(html); 16 | const links = root.querySelectorAll("a"); 17 | for (const link of links) { 18 | const href = link.getAttribute("href"); 19 | if (!href) continue; 20 | const url = new URL("/api/hooks/click", apiUrl); 21 | url.searchParams.set("messageId", messageId); 22 | url.searchParams.set("url", href); 23 | link.setAttribute("href", url.toString()); 24 | } 25 | 26 | // add open tracking pixel to end of body 27 | const body = root.querySelector("body"); 28 | if (body) { 29 | const img = parse( 30 | `` 31 | ); 32 | body.appendChild(img); 33 | } else { 34 | throw new Error("no body found in html"); 35 | } 36 | 37 | return root.toString(); 38 | } catch (e) { 39 | error("instrumentHtml error", e); 40 | throw e; 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /packages/core/src/util/serverLogger.ts: -------------------------------------------------------------------------------- 1 | import chalk from "chalk"; 2 | 3 | const { DEBUG, NODE_ENV } = process.env; 4 | 5 | const PREFIX = "mailing"; 6 | 7 | export function log(message?: any, ...optionalParams: any[]) { 8 | console.log(chalk.white(PREFIX), message, ...optionalParams); 9 | } 10 | 11 | export function error(message?: any, ...optionalParams: any[]) { 12 | // e.g. when there is a jest test like instrumentHtml.test.ts 13 | // that intentionally throws an error, don't log from a 14 | // try-catch that logs and re-raises the error 15 | if ("test" === NODE_ENV) return; 16 | console.error(chalk.red(PREFIX), message, ...optionalParams); 17 | } 18 | 19 | export function debug(message?: any, ...optionalParams: any[]) { 20 | if (DEBUG) 21 | console.info(chalk.yellowBright(PREFIX), message, ...optionalParams); 22 | } 23 | 24 | export function logPlain(message?: any, ...optionalParams: any[]) { 25 | console.log(message, ...optionalParams); 26 | } 27 | -------------------------------------------------------------------------------- /packages/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 | # next.js 12 | /.next/ 13 | /out/ 14 | 15 | # production 16 | /build 17 | 18 | # misc 19 | .DS_Store 20 | *.pem 21 | 22 | # debug 23 | npm-debug.log* 24 | yarn-debug.log* 25 | yarn-error.log* 26 | .pnpm-debug.log* 27 | 28 | # local env files 29 | .env*.local 30 | 31 | # vercel 32 | .vercel 33 | 34 | .mailing 35 | -------------------------------------------------------------------------------- /packages/web/README.md: -------------------------------------------------------------------------------- 1 | # Web 2 | 3 | ## Getting Started 4 | 5 | Run the development server: 6 | 7 | ```bash 8 | cd packages/web 9 | yarn dev 10 | ``` 11 | 12 | ### DB 13 | 14 | For DB features, web attaches to postgres via prisma. To develop locally, first add the WEB_DATABASE_URL to `packages/web/.env`. On mac it will look something like this: 15 | 16 | ```bash 17 | WEB_DATABASE_URL="postgresql://petersugihara@localhost:5432/mailing" 18 | ``` 19 | 20 | Then run migrate to create the DB and initialize the schema. 21 | 22 | ```bash 23 | cd packages/web 24 | npx prisma migrate dev 25 | ``` 26 | 27 | In prod, we set MAILING_DATABASE_URL to a postgres db on neon.tech. 28 | -------------------------------------------------------------------------------- /packages/web/components/Arrow.tsx: -------------------------------------------------------------------------------- 1 | export default function Arrow() { 2 | return   →; 3 | } 4 | -------------------------------------------------------------------------------- /packages/web/components/BlogLayout.tsx: -------------------------------------------------------------------------------- 1 | export default function BlogLayout({ children }) { 2 | return ( 3 |
4 |
{children}
5 |
6 | ); 7 | } 8 | -------------------------------------------------------------------------------- /packages/web/components/DefaultLayout.tsx: -------------------------------------------------------------------------------- 1 | export default function DefaultLayout({ children }) { 2 | return children; // identity! 3 | } 4 | -------------------------------------------------------------------------------- /packages/web/components/docs/NavLink.tsx: -------------------------------------------------------------------------------- 1 | import Link from "next/link"; 2 | import cx from "classnames"; 3 | 4 | type NavLinkProps = { 5 | href: string; 6 | children: React.ReactNode | React.ReactNode[]; 7 | active: string | false; 8 | className?: string; 9 | }; 10 | 11 | export default function DocsLink({ 12 | href, 13 | children, 14 | active, 15 | className, 16 | }: NavLinkProps) { 17 | const isActive = 18 | active === href || active === href + "/" || active + "#0" === href; 19 | 20 | return ( 21 |
22 | 36 | 42 | ● 43 | 44 | {children} 45 | 46 |
47 | ); 48 | } 49 | -------------------------------------------------------------------------------- /packages/web/components/homepage/H2.tsx: -------------------------------------------------------------------------------- 1 | import { useMemo } from "react"; 2 | import cx from "classnames"; 3 | 4 | function getAnchor(text: string) { 5 | return text 6 | .toLowerCase() 7 | .replace(/[^a-z0-9 ]/g, "") 8 | .replace(/[ ]/g, "-"); 9 | } 10 | 11 | type H2Props = { 12 | children: string; 13 | marginClassName?: string; 14 | }; 15 | 16 | export default function H2({ children, marginClassName }: H2Props) { 17 | const anchor = useMemo(() => getAnchor(children), [children]); 18 | 19 | return ( 20 |

31 |
{children}
32 |

33 | ); 34 | } 35 | -------------------------------------------------------------------------------- /packages/web/components/homepage/Li.tsx: -------------------------------------------------------------------------------- 1 | import type { ReactNode } from "react"; 2 | import cx from "classnames"; 3 | 4 | type LiProps = { 5 | title: string; 6 | description: ReactNode; 7 | index: number; 8 | prepend?: ReactNode; 9 | }; 10 | 11 | export default function Li({ title, description, index, prepend }: LiProps) { 12 | return ( 13 |
18 | {prepend} 19 |
20 |
21 | 22 | 0{index} 23 | 24 |    25 | {title} 26 |
27 |
28 | {description} 29 |
30 |
31 |
32 | ); 33 | } 34 | -------------------------------------------------------------------------------- /packages/web/components/hooks/useHydrationFriendlyAsPath.tsx: -------------------------------------------------------------------------------- 1 | import { useRouter } from "next/router"; 2 | import { useEffect, useState } from "react"; 3 | 4 | export const useHydrationFriendlyAsPath = () => { 5 | const { asPath } = useRouter(); 6 | const [ssr, setSsr] = useState(true); 7 | 8 | useEffect(() => { 9 | setSsr(false); 10 | }, []); 11 | 12 | return ssr ? asPath.split("#", 1)[0] : asPath; 13 | }; 14 | -------------------------------------------------------------------------------- /packages/web/components/mdx/A.tsx: -------------------------------------------------------------------------------- 1 | import { ReactNode } from "react"; 2 | import { InferProps } from "prop-types"; 3 | import Link from "next/link"; 4 | 5 | type AProps = InferProps & { 6 | children: ReactNode | ReactNode[]; 7 | }; 8 | 9 | export default function A({ children, ...anchorProps }: AProps) { 10 | const href: string = anchorProps.href; 11 | 12 | return href.startsWith("/") || href.startsWith("#") ? ( 13 | 18 | {children} 19 | 20 | ) : ( 21 | 27 | {children} 28 | 29 | ); 30 | } 31 | -------------------------------------------------------------------------------- /packages/web/components/mdx/Code.tsx: -------------------------------------------------------------------------------- 1 | import { ReactNode } from "react"; 2 | 3 | type CodeProps = { 4 | children: ReactNode | ReactNode[]; 5 | }; 6 | 7 | export default function Code({ children }: CodeProps) { 8 | return ( 9 | 10 | 11 | {children} 12 | 13 | 14 | ); 15 | } 16 | -------------------------------------------------------------------------------- /packages/web/components/mdx/CodeBlock.tsx: -------------------------------------------------------------------------------- 1 | import { ReactNode } from "react"; 2 | 3 | type CodeBlockProps = { 4 | children: ReactNode | ReactNode[]; 5 | language: "JavaScript" | "Ruby" | "yarn" | "npm"; 6 | }; 7 | 8 | export default function CodeBlock({ children }: CodeBlockProps) { 9 | return children; 10 | } 11 | -------------------------------------------------------------------------------- /packages/web/components/mdx/H1.tsx: -------------------------------------------------------------------------------- 1 | import { ReactNode } from "react"; 2 | import { useHydrationFriendlyAsPath } from "../hooks/useHydrationFriendlyAsPath"; 3 | import cx from "classnames"; 4 | 5 | type H1Props = { 6 | children: ReactNode | ReactNode[]; 7 | }; 8 | 9 | export default function H1({ children }: H1Props) { 10 | const asPath = useHydrationFriendlyAsPath(); 11 | 12 | return ( 13 |

14 | 22 | ● 23 | 24 | {children} 25 |

26 | ); 27 | } 28 | -------------------------------------------------------------------------------- /packages/web/components/mdx/H2.tsx: -------------------------------------------------------------------------------- 1 | import { useMemo } from "react"; 2 | import cx from "classnames"; 3 | import { useHydrationFriendlyAsPath } from "../hooks/useHydrationFriendlyAsPath"; 4 | import { getAnchor } from "./util/getAnchor"; 5 | 6 | type H2Props = { 7 | children: string; 8 | }; 9 | 10 | export default function H2({ children }: H2Props) { 11 | const asPath = useHydrationFriendlyAsPath(); 12 | const anchor = useMemo(() => getAnchor(children), [children]); 13 | const link = `#${anchor}`; 14 | 15 | return ( 16 |

17 | 32 |

33 | ); 34 | } 35 | -------------------------------------------------------------------------------- /packages/web/components/mdx/H3.tsx: -------------------------------------------------------------------------------- 1 | import { Children, useMemo } from "react"; 2 | import { getAnchor } from "./util/getAnchor"; 3 | 4 | type H3Props = { 5 | children: string; 6 | }; 7 | 8 | export default function H3({ children }: H3Props) { 9 | const anchor = useMemo( 10 | () => 11 | getAnchor( 12 | Children.map(children, (child) => 13 | "string" === typeof child ? child : null 14 | ).join("-") 15 | ), 16 | [children] 17 | ); 18 | 19 | return ( 20 |

24 | {children} 25 |

26 | ); 27 | } 28 | -------------------------------------------------------------------------------- /packages/web/components/mdx/H4.tsx: -------------------------------------------------------------------------------- 1 | import { Children, useMemo } from "react"; 2 | import { getAnchor } from "./util/getAnchor"; 3 | 4 | type H4Props = { 5 | children: string; 6 | }; 7 | 8 | export default function H4({ children }: H4Props) { 9 | const anchor = useMemo( 10 | () => 11 | getAnchor( 12 | Children.map(children, (child) => 13 | "string" === typeof child ? child : null 14 | ).join("-") 15 | ), 16 | [children] 17 | ); 18 | 19 | return ( 20 |

24 | {children} 25 |

26 | ); 27 | } 28 | -------------------------------------------------------------------------------- /packages/web/components/mdx/Li.tsx: -------------------------------------------------------------------------------- 1 | import { ReactNode } from "react"; 2 | 3 | type LiProps = { 4 | children: ReactNode | ReactNode[]; 5 | }; 6 | 7 | export default function Li({ children }: LiProps) { 8 | return
  • {children}
  • ; 9 | } 10 | -------------------------------------------------------------------------------- /packages/web/components/mdx/MDXComponents.tsx: -------------------------------------------------------------------------------- 1 | import type { Components } from "@mdx-js/react/lib"; 2 | 3 | import H1 from "./H1"; 4 | import H2 from "./H2"; 5 | import H3 from "./H3"; 6 | import H4 from "./H4"; 7 | import A from "./A"; 8 | import P from "./P"; 9 | import Li from "./Li"; 10 | import Ul from "./Ul"; 11 | import Code from "./Code"; 12 | import Pre from "./Pre"; 13 | 14 | const MDXComponents: Components = { 15 | h1: H1, 16 | h2: H2, 17 | h3: H3, 18 | h4: H4, 19 | a: A, 20 | p: P, 21 | li: Li, 22 | ul: Ul, 23 | code: Code, 24 | pre: Pre, 25 | }; 26 | 27 | export default MDXComponents; 28 | -------------------------------------------------------------------------------- /packages/web/components/mdx/P.tsx: -------------------------------------------------------------------------------- 1 | import { ReactNode } from "react"; 2 | 3 | type PProps = { 4 | children: ReactNode | ReactNode[]; 5 | }; 6 | 7 | export default function P({ children }: PProps) { 8 | return ( 9 |

    {children}

    10 | ); 11 | } 12 | -------------------------------------------------------------------------------- /packages/web/components/mdx/Pre.tsx: -------------------------------------------------------------------------------- 1 | import { ReactNode } from "react"; 2 | 3 | type PreProps = { 4 | children: ReactNode | ReactNode[]; 5 | reducePadding: boolean; 6 | }; 7 | 8 | export default function Pre({ children }: PreProps) { 9 | return ( 10 | 11 |
    12 |         {children}
    13 |       
    14 | 23 |
    24 | ); 25 | } 26 | -------------------------------------------------------------------------------- /packages/web/components/mdx/Ul.tsx: -------------------------------------------------------------------------------- 1 | import { ReactNode } from "react"; 2 | 3 | type UlProps = { 4 | children: ReactNode | ReactNode[]; 5 | }; 6 | 7 | export default function Ul({ children }: UlProps) { 8 | return
      {children}
    ; 9 | } 10 | -------------------------------------------------------------------------------- /packages/web/components/mdx/util/__test__/getAnchor.test.ts: -------------------------------------------------------------------------------- 1 | import { getAnchor } from "../getAnchor"; 2 | 3 | describe("getAnchor", () => { 4 | it("should return an anchor", () => { 5 | expect(getAnchor("Hello World")).toBe("hello-world"); 6 | }); 7 | it("should return an anchor with /, replace special characters at start and end", () => { 8 | expect(getAnchor("/api/render/ ")).toBe("api-render"); 9 | }); 10 | }); 11 | -------------------------------------------------------------------------------- /packages/web/components/mdx/util/getAnchor.tsx: -------------------------------------------------------------------------------- 1 | export function getAnchor(text: string) { 2 | return text 3 | .toLowerCase() 4 | .replace(/\//g, "-") 5 | .replace(/[^a-z0-9- ]/g, "") 6 | .replace(/[ ]/g, "-") 7 | .replace(/^[-]+/, "") 8 | .replace(/[-]+$/, ""); 9 | } 10 | -------------------------------------------------------------------------------- /packages/web/components/white-glove/H2.tsx: -------------------------------------------------------------------------------- 1 | import { useMemo } from "react"; 2 | import cx from "classnames"; 3 | 4 | function getAnchor(text) { 5 | return text 6 | .toLowerCase() 7 | .replace(/[^a-z0-9 ]/g, "") 8 | .replace(/[ ]/g, "-"); 9 | } 10 | 11 | type H2Props = { 12 | children: string; 13 | }; 14 | 15 | export default function H2({ children }: H2Props) { 16 | const anchor = useMemo(() => getAnchor(children), [children]); 17 | const link = `#${anchor}`; 18 | 19 | return ( 20 |

    24 | 29 |

    30 | ); 31 | } 32 | -------------------------------------------------------------------------------- /packages/web/components/white-glove/Li.tsx: -------------------------------------------------------------------------------- 1 | import type { ReactNode } from "react"; 2 | import cx from "classnames"; 3 | 4 | type LiProps = { 5 | title: string; 6 | description: ReactNode; 7 | index: number; 8 | }; 9 | 10 | export default function Li({ title, description, index }: LiProps) { 11 | return ( 12 |
    1, 15 | "mt-8 sm:mt-12 md:mt-24": index === 1, 16 | })} 17 | > 18 |
    19 | {index} 20 |
    21 |
    22 |
    {title}
    23 |
    {description}
    24 |
    25 |
    26 | ); 27 | } 28 | -------------------------------------------------------------------------------- /packages/web/components/white-glove/Subheading.tsx: -------------------------------------------------------------------------------- 1 | import type { InferProps } from "prop-types"; 2 | import cx from "classnames"; 3 | 4 | type SubheadingProps = InferProps & { 5 | className?: string; 6 | }; 7 | 8 | export default function Subheading({ 9 | children, 10 | className, 11 | ...divProps 12 | }: SubheadingProps) { 13 | return ( 14 |
    21 | {children} 22 | 32 |
    33 | ); 34 | } 35 | -------------------------------------------------------------------------------- /packages/web/emails/assets/discord.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sofn-xyz/mailing/90af52dd9d67089430be485449858b95322f9fca/packages/web/emails/assets/discord.png -------------------------------------------------------------------------------- /packages/web/emails/assets/force-deliver.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sofn-xyz/mailing/90af52dd9d67089430be485449858b95322f9fca/packages/web/emails/assets/force-deliver.png -------------------------------------------------------------------------------- /packages/web/emails/assets/github.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sofn-xyz/mailing/90af52dd9d67089430be485449858b95322f9fca/packages/web/emails/assets/github.png -------------------------------------------------------------------------------- /packages/web/emails/assets/header-background.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sofn-xyz/mailing/90af52dd9d67089430be485449858b95322f9fca/packages/web/emails/assets/header-background.png -------------------------------------------------------------------------------- /packages/web/emails/assets/header-side.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sofn-xyz/mailing/90af52dd9d67089430be485449858b95322f9fca/packages/web/emails/assets/header-side.png -------------------------------------------------------------------------------- /packages/web/emails/assets/html-linter.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sofn-xyz/mailing/90af52dd9d67089430be485449858b95322f9fca/packages/web/emails/assets/html-linter.png -------------------------------------------------------------------------------- /packages/web/emails/assets/logo-full.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sofn-xyz/mailing/90af52dd9d67089430be485449858b95322f9fca/packages/web/emails/assets/logo-full.png -------------------------------------------------------------------------------- /packages/web/emails/assets/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sofn-xyz/mailing/90af52dd9d67089430be485449858b95322f9fca/packages/web/emails/assets/logo.png -------------------------------------------------------------------------------- /packages/web/emails/assets/mailing-lists.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sofn-xyz/mailing/90af52dd9d67089430be485449858b95322f9fca/packages/web/emails/assets/mailing-lists.png -------------------------------------------------------------------------------- /packages/web/emails/assets/thanks.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sofn-xyz/mailing/90af52dd9d67089430be485449858b95322f9fca/packages/web/emails/assets/thanks.png -------------------------------------------------------------------------------- /packages/web/emails/assets/white-glove.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sofn-xyz/mailing/90af52dd9d67089430be485449858b95322f9fca/packages/web/emails/assets/white-glove.png -------------------------------------------------------------------------------- /packages/web/emails/assets/zero-point-nine.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sofn-xyz/mailing/90af52dd9d67089430be485449858b95322f9fca/packages/web/emails/assets/zero-point-nine.gif -------------------------------------------------------------------------------- /packages/web/emails/components/Button.tsx: -------------------------------------------------------------------------------- 1 | import { MjmlButton } from "@faire/mjml-react"; 2 | 3 | import { 4 | colors, 5 | fontSize, 6 | lineHeight, 7 | borderRadius, 8 | fontWeight, 9 | } from "../theme"; 10 | 11 | type ButtonProps = { secondary?: boolean } & React.ComponentProps< 12 | typeof MjmlButton 13 | >; 14 | 15 | export default function Button({ secondary, ...props }: ButtonProps) { 16 | let secondaryStyles = {}; 17 | if (secondary) { 18 | secondaryStyles = { 19 | color: colors.white, 20 | backgroundColor: colors.amber200, 21 | }; 22 | } 23 | return ( 24 | 36 | ); 37 | } 38 | -------------------------------------------------------------------------------- /packages/web/emails/components/Heading.tsx: -------------------------------------------------------------------------------- 1 | import Text from "./Text"; 2 | import { fontFamily, lineHeight, fontWeight } from "../theme"; 3 | 4 | type HeadingProps = { 5 | lg?: Partial>; 6 | sm?: Partial>; 7 | } & React.ComponentProps; 8 | 9 | export default function Heading({ lg, sm, ...props }: HeadingProps) { 10 | const defaultProps = { 11 | fontFamily: fontFamily.sans, 12 | fontWeight: fontWeight.bold, 13 | lineHeight: lineHeight.tight, 14 | }; 15 | 16 | return ( 17 | <> 18 | 19 | {props.children} 20 | 21 | 22 | {props.children} 23 | 24 | 25 | ); 26 | } 27 | -------------------------------------------------------------------------------- /packages/web/emails/components/List.tsx: -------------------------------------------------------------------------------- 1 | import { MjmlRaw } from "@faire/mjml-react"; 2 | 3 | import { fontSize, themeDefaults } from "../theme"; 4 | 5 | type ListProps = { 6 | items: string[]; 7 | } & React.ComponentProps; 8 | 9 | export default function List({ items }: ListProps) { 10 | return ( 11 | 12 | 13 | 14 | 21 | {items.map((item, index) => ( 22 | 23 | 31 | 34 | 35 | ))} 36 |
    29 | • 30 | 32 | {item} 33 |
    37 | 38 | 39 |
    40 | ); 41 | } 42 | -------------------------------------------------------------------------------- /packages/web/emails/components/Spacer.tsx: -------------------------------------------------------------------------------- 1 | import { MjmlSpacer } from "@faire/mjml-react"; 2 | 3 | type SpacerProps = { 4 | sm?: React.ComponentProps; 5 | lg?: React.ComponentProps; 6 | } & React.ComponentProps; 7 | 8 | export default function Spacer({ sm, lg, ...props }: SpacerProps) { 9 | return ( 10 | <> 11 | 12 | 13 | 14 | ); 15 | } 16 | -------------------------------------------------------------------------------- /packages/web/emails/components/Text.tsx: -------------------------------------------------------------------------------- 1 | import { MjmlText } from "@faire/mjml-react"; 2 | 3 | type TextProps = { 4 | maxWidth?: number; 5 | style?: React.CSSProperties; 6 | } & React.ComponentProps; 7 | 8 | export default function Text({ children, maxWidth, ...props }: TextProps) { 9 | if (maxWidth) { 10 | return ( 11 | 12 |
    {children}
    13 |
    14 | ); 15 | } else return {children}; 16 | } 17 | -------------------------------------------------------------------------------- /packages/web/emails/index.ts: -------------------------------------------------------------------------------- 1 | import nodemailer from "nodemailer"; 2 | import { buildSendMail } from "mailing-core"; 3 | 4 | const transport = nodemailer.createTransport({ 5 | host: "email-smtp.us-east-1.amazonaws.com", 6 | pool: true, 7 | port: 587, 8 | auth: { 9 | user: process.env.MAILING_SES_USER, 10 | pass: process.env.MAILING_SES_PASSWORD, 11 | }, 12 | }); 13 | 14 | const sendMail = buildSendMail({ 15 | transport, 16 | defaultFrom: "Mailing Team ", 17 | configPath: "./mailing.config.json", 18 | }); 19 | 20 | export default sendMail; 21 | -------------------------------------------------------------------------------- /packages/web/emails/previews/Newsletter.tsx: -------------------------------------------------------------------------------- 1 | import Newsletter from "../Newsletter"; 2 | 3 | export function preview() { 4 | return ; 5 | } 6 | -------------------------------------------------------------------------------- /packages/web/emails/util/assetUrl.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * If you want to use local assets while developing new templates, 3 | * run `yarn dev:local-assets` instead of `yarn dev` 4 | * 5 | * NOTE: This will cause the email linter to throw errors 6 | */ 7 | 8 | const ASSET_URL = (process.env.NEXT_PUBLIC_VERCEL_URL && `https://${process.env.NEXT_PUBLIC_VERCEL_URL}`) || process.env.MAILING_API_URL || "https://emails.mailing.run"; 9 | 10 | export default function assetUrl(url: string) { 11 | if (url.startsWith("/") && !process.env.LOCAL_ASSETS) { 12 | return `${ASSET_URL}${url}`; 13 | } 14 | 15 | return url; 16 | } 17 | -------------------------------------------------------------------------------- /packages/web/emails/util/cssHelpers.ts: -------------------------------------------------------------------------------- 1 | import { screens } from "../theme"; 2 | 3 | const cssHelpers = ` 4 | /* Utility classes */ 5 | .no-wrap { 6 | white-space: nowrap; 7 | } 8 | .hidden { 9 | display: none; 10 | max-width: 0px; 11 | max-height: 0px; 12 | overflow: hidden; 13 | mso-hide: all; 14 | } 15 | .lg-hidden { 16 | display: none; 17 | max-width: 0px; 18 | max-height: 0px; 19 | overflow: hidden; 20 | mso-hide: all; 21 | } 22 | 23 | @media (min-width: ${screens.xs}) { 24 | /* Utility classes */ 25 | .sm-hidden { 26 | display: none; 27 | max-width: 0px; 28 | max-height: 0px; 29 | overflow: hidden; 30 | mso-hide: all; 31 | } 32 | .lg-hidden { 33 | display: block !important; 34 | max-width: none !important; 35 | max-height: none !important; 36 | overflow: visible !important; 37 | mso-hide: none !important; 38 | } 39 | } 40 | `; 41 | 42 | export default cssHelpers; 43 | -------------------------------------------------------------------------------- /packages/web/mailing.config.json: -------------------------------------------------------------------------------- 1 | { 2 | "typescript": true, 3 | "emailsDir": "./emails", 4 | "outDir": "./previews_html", 5 | "anonymousId": "238e2146-cfb2-4ccd-89f8-d0862b27fa0e" 6 | } 7 | -------------------------------------------------------------------------------- /packages/web/netlify.toml: -------------------------------------------------------------------------------- 1 | [[plugins]] 2 | package = "@netlify/plugin-nextjs" 3 | 4 | [build] 5 | command = "yarn build" 6 | publish = ".next" 7 | 8 | [build.environment] 9 | NETLIFY_USE_YARN = "true" 10 | -------------------------------------------------------------------------------- /packages/web/next-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | 4 | // NOTE: This file should not be edited 5 | // see https://nextjs.org/docs/basic-features/typescript for more information. 6 | -------------------------------------------------------------------------------- /packages/web/next.config.mjs: -------------------------------------------------------------------------------- 1 | import gfm from "remark-gfm"; 2 | import nextMDX from "@next/mdx"; 3 | import rehypeHighlight from "rehype-highlight"; 4 | 5 | const withMDX = nextMDX({ 6 | extension: /\.mdx?$/, 7 | options: { 8 | remarkPlugins: [gfm], 9 | rehypePlugins: [rehypeHighlight], 10 | providerImportSource: "@mdx-js/react", 11 | }, 12 | }); 13 | 14 | /** @type {import('next').NextConfig} */ 15 | const nextConfig = { 16 | reactStrictMode: true, 17 | swcMinify: true, 18 | // Append the default value with md extensions 19 | pageExtensions: ["ts", "tsx", "js", "jsx", "md", "mdx"], 20 | }; 21 | 22 | export default withMDX(nextConfig); 23 | -------------------------------------------------------------------------------- /packages/web/pages/_document.tsx: -------------------------------------------------------------------------------- 1 | import { Html, Head, Main, NextScript } from "next/document"; 2 | 3 | export default function Document() { 4 | return ( 5 | 6 | 7 | 8 | 9 | 10 |
    11 | 12 | 13 | 14 | ); 15 | } 16 | -------------------------------------------------------------------------------- /packages/web/pages/api/__integration__/newsletterSubscribers.test.ts: -------------------------------------------------------------------------------- 1 | import { NextApiRequest, NextApiResponse } from "next"; 2 | import handler from "../newsletterSubscribers"; 3 | 4 | function mockRequestResponse(method: string) { 5 | const { req, res } = { 6 | req: { method } as NextApiRequest, 7 | res: {} as unknown as NextApiResponse, 8 | }; 9 | res.json = jest.fn(); 10 | res.end = jest.fn(); 11 | res.status = jest.fn(() => res); 12 | req.headers = { "Content-Type": "application/json" }; 13 | return { req, res }; 14 | } 15 | 16 | describe("users api", () => { 17 | describe("create", () => { 18 | it("404s on GET", async () => { 19 | const { req, res } = mockRequestResponse("GET"); 20 | await handler(req, res); 21 | expect(res.status).toHaveBeenCalledWith(404); 22 | }); 23 | }); 24 | }); 25 | -------------------------------------------------------------------------------- /packages/web/pages/api/newsletterSubscribers.ts: -------------------------------------------------------------------------------- 1 | import type { NextApiRequest, NextApiResponse } from "next"; 2 | import prisma from "../../prisma"; 3 | 4 | type Data = { 5 | error?: string; 6 | }; 7 | 8 | const handler = async (req: NextApiRequest, res: NextApiResponse) => { 9 | if (req.method !== "POST") return res.status(404).end(); 10 | 11 | const email = req.body?.email; 12 | if (typeof email !== "string") { 13 | return res.status(422).json({ error: "email not provided" }); 14 | } 15 | 16 | const newsletterSubscriber = await prisma.newsletterSubscriber.findFirst({ 17 | where: { email }, 18 | }); 19 | if (newsletterSubscriber) { 20 | return res.status(200).end(); 21 | } 22 | 23 | await prisma.newsletterSubscriber.create({ 24 | data: { 25 | email, 26 | ip: 27 | req.headers["x-forwarded-for"]?.toString() || 28 | req.socket?.remoteAddress || 29 | "", 30 | }, 31 | }); 32 | 33 | res.status(201).end(); 34 | }; 35 | 36 | export default handler; 37 | -------------------------------------------------------------------------------- /packages/web/pages/blog/first-post.mdx: -------------------------------------------------------------------------------- 1 | <> 2 |
    3 | # Hello World 4 | 5 | This is the first blog post on the blog. 6 | 7 | It works pretty well. We'll be writing some code probably: 8 | 9 | ``` 10 | 13 | ``` 14 | 15 |
    16 | 17 | -------------------------------------------------------------------------------- /packages/web/pages/docs/discord.mdx: -------------------------------------------------------------------------------- 1 | # Discord 2 | 3 | Need help getting set up? Have a question about Mailing? Want to chat with other users? 4 | 5 | Join our Discord server! [![](https://dcbadge.vercel.app/api/server/fdSzmY46wY?style=flat)](https://discord.gg/fdSzmY46wY) 6 | -------------------------------------------------------------------------------- /packages/web/pages/docs/platform.mdx: -------------------------------------------------------------------------------- 1 | # Platform 2 | 3 | ## What’s Platform? 4 | 5 | ### The email platform for teams that code 6 | 7 | Mailing is free. Mailing Platform is the advanced version with paid features. It requires a deployment, associated database, and API key. It's designed to give you everything you need to send both marketing and transactional emails. 8 | 9 | - Easy unsubscribe links 10 | - Mailing lists 11 | - Still Open Source 12 | - Self-hosted 13 | 14 | ## Setup 15 | 16 | To enable Mailing Platform, deploy Mailing and add a database to set up an API key. Instructions for doing this can be found in the [deployment guide](/docs/deploy). 17 | 18 | Once you're done, you can start using [Lists](/docs/lists) to allow users to manage subscription preferences. 19 | -------------------------------------------------------------------------------- /packages/web/postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | }; 7 | -------------------------------------------------------------------------------- /packages/web/prisma/__mocks__/index.ts: -------------------------------------------------------------------------------- 1 | import { PrismaClient } from "../generated/client"; 2 | import { mockDeep, mockReset, DeepMockProxy } from "jest-mock-extended"; 3 | 4 | import prisma from ".."; 5 | 6 | jest.mock("..", () => ({ 7 | __esModule: true, 8 | default: mockDeep(), 9 | })); 10 | 11 | beforeEach(() => { 12 | mockReset(prismaMock); 13 | }); 14 | 15 | export const prismaMock = prisma as unknown as DeepMockProxy; 16 | -------------------------------------------------------------------------------- /packages/web/prisma/index.ts: -------------------------------------------------------------------------------- 1 | import { PrismaClient } from "./generated/client"; 2 | 3 | declare global { 4 | // eslint-disable-next-line no-var 5 | var prismaMailingWeb: PrismaClient | undefined; 6 | } 7 | 8 | const prisma = global.prismaMailingWeb || new PrismaClient(); 9 | 10 | if (process.env.NODE_ENV === "development") global.prismaMailingWeb = prisma; 11 | 12 | export default prisma; 13 | -------------------------------------------------------------------------------- /packages/web/prisma/migrations/20220809190025_init/migration.sql: -------------------------------------------------------------------------------- 1 | -- CreateTable 2 | CREATE TABLE "User" ( 3 | "id" TEXT NOT NULL, 4 | "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, 5 | "email" TEXT NOT NULL, 6 | "ip" TEXT NOT NULL, 7 | 8 | CONSTRAINT "User_pkey" PRIMARY KEY ("id") 9 | ); 10 | 11 | -- CreateIndex 12 | CREATE UNIQUE INDEX "User_email_key" ON "User"("email"); 13 | -------------------------------------------------------------------------------- /packages/web/prisma/migrations/20220927220803_rename_users_to_newsletter_subscribers/migration.sql: -------------------------------------------------------------------------------- 1 | -- This is an empty migration. 2 | ALTER TABLE "User" RENAME TO "NewsletterSubscriber"; 3 | ALTER INDEX "User_pkey" RENAME TO "NewsletterSubscriber_pkey"; 4 | ALTER INDEX "User_email_key" RENAME TO "NewsletterSubscriber_email_key"; 5 | -------------------------------------------------------------------------------- /packages/web/prisma/migrations/migration_lock.toml: -------------------------------------------------------------------------------- 1 | # Please do not edit this file manually 2 | # It should be added in your version-control system (i.e. Git) 3 | provider = "postgresql" -------------------------------------------------------------------------------- /packages/web/prisma/schema.prisma: -------------------------------------------------------------------------------- 1 | generator client { 2 | provider = "prisma-client-js" 3 | output = "./generated/client" 4 | } 5 | 6 | datasource db { 7 | provider = "postgresql" 8 | // not DATABASE_URL so that web+cli can run on the same machine easily 9 | url = env("WEB_DATABASE_URL") 10 | } 11 | 12 | model NewsletterSubscriber { 13 | id String @id @default(cuid()) 14 | createdAt DateTime @default(now()) 15 | email String @unique 16 | ip String 17 | } 18 | -------------------------------------------------------------------------------- /packages/web/public/android-chrome-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sofn-xyz/mailing/90af52dd9d67089430be485449858b95322f9fca/packages/web/public/android-chrome-192x192.png -------------------------------------------------------------------------------- /packages/web/public/android-chrome-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sofn-xyz/mailing/90af52dd9d67089430be485449858b95322f9fca/packages/web/public/android-chrome-512x512.png -------------------------------------------------------------------------------- /packages/web/public/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sofn-xyz/mailing/90af52dd9d67089430be485449858b95322f9fca/packages/web/public/apple-touch-icon.png -------------------------------------------------------------------------------- /packages/web/public/browserconfig.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | #00aba9 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /packages/web/public/discord-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sofn-xyz/mailing/90af52dd9d67089430be485449858b95322f9fca/packages/web/public/discord-icon.png -------------------------------------------------------------------------------- /packages/web/public/email-prefs-screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sofn-xyz/mailing/90af52dd9d67089430be485449858b95322f9fca/packages/web/public/email-prefs-screenshot.png -------------------------------------------------------------------------------- /packages/web/public/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sofn-xyz/mailing/90af52dd9d67089430be485449858b95322f9fca/packages/web/public/favicon-16x16.png -------------------------------------------------------------------------------- /packages/web/public/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sofn-xyz/mailing/90af52dd9d67089430be485449858b95322f9fca/packages/web/public/favicon-32x32.png -------------------------------------------------------------------------------- /packages/web/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sofn-xyz/mailing/90af52dd9d67089430be485449858b95322f9fca/packages/web/public/favicon.ico -------------------------------------------------------------------------------- /packages/web/public/gh-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sofn-xyz/mailing/90af52dd9d67089430be485449858b95322f9fca/packages/web/public/gh-icon.png -------------------------------------------------------------------------------- /packages/web/public/homepage/circle-jar/HowItWorksJar1.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /packages/web/public/homepage/circle-jar/HowItWorksJar2.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /packages/web/public/homepage/demo-theme-skinny.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sofn-xyz/mailing/90af52dd9d67089430be485449858b95322f9fca/packages/web/public/homepage/demo-theme-skinny.png -------------------------------------------------------------------------------- /packages/web/public/homepage/demo-theme.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sofn-xyz/mailing/90af52dd9d67089430be485449858b95322f9fca/packages/web/public/homepage/demo-theme.png -------------------------------------------------------------------------------- /packages/web/public/homepage/fynn-code-lg.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sofn-xyz/mailing/90af52dd9d67089430be485449858b95322f9fca/packages/web/public/homepage/fynn-code-lg.png -------------------------------------------------------------------------------- /packages/web/public/homepage/fynn-code-sample.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sofn-xyz/mailing/90af52dd9d67089430be485449858b95322f9fca/packages/web/public/homepage/fynn-code-sample.png -------------------------------------------------------------------------------- /packages/web/public/homepage/fynn-code-sm.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sofn-xyz/mailing/90af52dd9d67089430be485449858b95322f9fca/packages/web/public/homepage/fynn-code-sm.png -------------------------------------------------------------------------------- /packages/web/public/homepage/fynn-code.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sofn-xyz/mailing/90af52dd9d67089430be485449858b95322f9fca/packages/web/public/homepage/fynn-code.png -------------------------------------------------------------------------------- /packages/web/public/homepage/fynn-screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sofn-xyz/mailing/90af52dd9d67089430be485449858b95322f9fca/packages/web/public/homepage/fynn-screenshot.png -------------------------------------------------------------------------------- /packages/web/public/homepage/list-screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sofn-xyz/mailing/90af52dd9d67089430be485449858b95322f9fca/packages/web/public/homepage/list-screenshot.png -------------------------------------------------------------------------------- /packages/web/public/homepage/prefs-screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sofn-xyz/mailing/90af52dd9d67089430be485449858b95322f9fca/packages/web/public/homepage/prefs-screenshot.png -------------------------------------------------------------------------------- /packages/web/public/homepage/previewer-screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sofn-xyz/mailing/90af52dd9d67089430be485449858b95322f9fca/packages/web/public/homepage/previewer-screenshot.png -------------------------------------------------------------------------------- /packages/web/public/homepage/testimonial-cv@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sofn-xyz/mailing/90af52dd9d67089430be485449858b95322f9fca/packages/web/public/homepage/testimonial-cv@2x.png -------------------------------------------------------------------------------- /packages/web/public/homepage/testimonial-email@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sofn-xyz/mailing/90af52dd9d67089430be485449858b95322f9fca/packages/web/public/homepage/testimonial-email@2x.png -------------------------------------------------------------------------------- /packages/web/public/homepage/testimonial-gr@2x.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sofn-xyz/mailing/90af52dd9d67089430be485449858b95322f9fca/packages/web/public/homepage/testimonial-gr@2x.jpeg -------------------------------------------------------------------------------- /packages/web/public/homepage/testimonial-sd@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sofn-xyz/mailing/90af52dd9d67089430be485449858b95322f9fca/packages/web/public/homepage/testimonial-sd@2x.png -------------------------------------------------------------------------------- /packages/web/public/homepage/testimonial-st@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sofn-xyz/mailing/90af52dd9d67089430be485449858b95322f9fca/packages/web/public/homepage/testimonial-st@2x.png -------------------------------------------------------------------------------- /packages/web/public/homepage/testimonial-wv@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sofn-xyz/mailing/90af52dd9d67089430be485449858b95322f9fca/packages/web/public/homepage/testimonial-wv@2x.png -------------------------------------------------------------------------------- /packages/web/public/icon-gh@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sofn-xyz/mailing/90af52dd9d67089430be485449858b95322f9fca/packages/web/public/icon-gh@2x.png -------------------------------------------------------------------------------- /packages/web/public/icon-twitter.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /packages/web/public/mstile-144x144.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sofn-xyz/mailing/90af52dd9d67089430be485449858b95322f9fca/packages/web/public/mstile-144x144.png -------------------------------------------------------------------------------- /packages/web/public/mstile-150x150.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sofn-xyz/mailing/90af52dd9d67089430be485449858b95322f9fca/packages/web/public/mstile-150x150.png -------------------------------------------------------------------------------- /packages/web/public/mstile-310x150.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sofn-xyz/mailing/90af52dd9d67089430be485449858b95322f9fca/packages/web/public/mstile-310x150.png -------------------------------------------------------------------------------- /packages/web/public/mstile-310x310.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sofn-xyz/mailing/90af52dd9d67089430be485449858b95322f9fca/packages/web/public/mstile-310x310.png -------------------------------------------------------------------------------- /packages/web/public/mstile-70x70.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sofn-xyz/mailing/90af52dd9d67089430be485449858b95322f9fca/packages/web/public/mstile-70x70.png -------------------------------------------------------------------------------- /packages/web/public/og-image.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sofn-xyz/mailing/90af52dd9d67089430be485449858b95322f9fca/packages/web/public/og-image.jpg -------------------------------------------------------------------------------- /packages/web/public/og-twitter.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sofn-xyz/mailing/90af52dd9d67089430be485449858b95322f9fca/packages/web/public/og-twitter.jpg -------------------------------------------------------------------------------- /packages/web/public/site.webmanifest: -------------------------------------------------------------------------------- 1 | { 2 | "name": "", 3 | "short_name": "", 4 | "icons": [ 5 | { 6 | "src": "/android-chrome-192x192.png", 7 | "sizes": "192x192", 8 | "type": "image/png" 9 | }, 10 | { 11 | "src": "/android-chrome-512x512.png", 12 | "sizes": "512x512", 13 | "type": "image/png" 14 | } 15 | ], 16 | "theme_color": "#ffffff", 17 | "background_color": "#ffffff", 18 | "display": "standalone" 19 | } 20 | -------------------------------------------------------------------------------- /packages/web/public/welcome-template/discord.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sofn-xyz/mailing/90af52dd9d67089430be485449858b95322f9fca/packages/web/public/welcome-template/discord.png -------------------------------------------------------------------------------- /packages/web/public/welcome-template/github.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sofn-xyz/mailing/90af52dd9d67089430be485449858b95322f9fca/packages/web/public/welcome-template/github.png -------------------------------------------------------------------------------- /packages/web/public/welcome-template/logo-full.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sofn-xyz/mailing/90af52dd9d67089430be485449858b95322f9fca/packages/web/public/welcome-template/logo-full.png -------------------------------------------------------------------------------- /packages/web/public/welcome-template/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sofn-xyz/mailing/90af52dd9d67089430be485449858b95322f9fca/packages/web/public/welcome-template/logo.png -------------------------------------------------------------------------------- /packages/web/public/white-glove/bbeam.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sofn-xyz/mailing/90af52dd9d67089430be485449858b95322f9fca/packages/web/public/white-glove/bbeam.png -------------------------------------------------------------------------------- /packages/web/public/white-glove/bookbook.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sofn-xyz/mailing/90af52dd9d67089430be485449858b95322f9fca/packages/web/public/white-glove/bookbook.png -------------------------------------------------------------------------------- /packages/web/public/white-glove/fynn.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sofn-xyz/mailing/90af52dd9d67089430be485449858b95322f9fca/packages/web/public/white-glove/fynn.png -------------------------------------------------------------------------------- /packages/web/public/white-glove/lancey.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sofn-xyz/mailing/90af52dd9d67089430be485449858b95322f9fca/packages/web/public/white-glove/lancey.png -------------------------------------------------------------------------------- /packages/web/public/white-glove/mailing.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sofn-xyz/mailing/90af52dd9d67089430be485449858b95322f9fca/packages/web/public/white-glove/mailing.png -------------------------------------------------------------------------------- /packages/web/public/white-glove/thoughtful-post.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sofn-xyz/mailing/90af52dd9d67089430be485449858b95322f9fca/packages/web/public/white-glove/thoughtful-post.png -------------------------------------------------------------------------------- /packages/web/public/white-glove/white-glove_og-image.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sofn-xyz/mailing/90af52dd9d67089430be485449858b95322f9fca/packages/web/public/white-glove/white-glove_og-image.jpg -------------------------------------------------------------------------------- /packages/web/public/white-glove/white-glove_og-twitter.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sofn-xyz/mailing/90af52dd9d67089430be485449858b95322f9fca/packages/web/public/white-glove/white-glove_og-twitter.jpg -------------------------------------------------------------------------------- /packages/web/tailwind.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('tailwindcss').Config} */ 2 | 3 | const theme = require("../cli/theme"); 4 | 5 | module.exports = { 6 | content: [ 7 | "./pages/**/*.{js,ts,jsx,tsx,mdx}", 8 | "./components/**/*.{js,ts,jsx,tsx}", 9 | ], 10 | theme, 11 | plugins: [require("@tailwindcss/typography")], 12 | }; 13 | -------------------------------------------------------------------------------- /packages/web/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": true, 6 | "skipLibCheck": true, 7 | "strict": false, 8 | "forceConsistentCasingInFileNames": true, 9 | "noEmit": true, 10 | "esModuleInterop": true, 11 | "module": "esnext", 12 | "moduleResolution": "node", 13 | "resolveJsonModule": true, 14 | "isolatedModules": true, 15 | "jsx": "preserve", 16 | "incremental": true 17 | }, 18 | "include": ["global.d.ts", "next-env.d.ts", "**/*.ts", "**/*.tsx"], 19 | "exclude": ["dist", "node_modules"] 20 | } 21 | -------------------------------------------------------------------------------- /scripts/assert-free-ports: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | lsof -i :3000 > /dev/null && echo 'Refusing to run because port 3000 is already in use' && exit 1 4 | lsof -i :3883 > /dev/null && echo 'Refusing to run because port 3883 is already in use' && exit 1 5 | 6 | exit 0 -------------------------------------------------------------------------------- /scripts/generate-emails: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -o nounset 4 | 5 | # Transpiles typescript email templates to javascript. 6 | # 7 | # Usage: ./scripts/generate-emails 8 | 9 | IN="packages/cli/src/$1" 10 | OUT="packages/cli/src/$1-js" 11 | 12 | # Generate JS templates from TS Templates 13 | npx tsc \ 14 | --allowSyntheticDefaultImports true \ 15 | --moduleResolution node \ 16 | --jsx preserve \ 17 | --target es2020 \ 18 | --outDir "$OUT" \ 19 | --noEmit false \ 20 | $IN/**/*.tsx $IN/*.tsx $IN/*.ts 21 | 22 | # Format the generated JS templates 23 | npx prettier --write $OUT/* 24 | yarn eslint --fix --ext .jsx,.js $OUT/* 25 | 26 | # Add the generated JS templates to git 27 | git add $OUT/* 28 | -------------------------------------------------------------------------------- /testSetup.integration.ts: -------------------------------------------------------------------------------- 1 | import { disconnectDatabases, truncateDatabases } from "./testUtilIntegration"; 2 | import chalk from "chalk"; 3 | 4 | if (!process.env.MAILING_INTEGRATION_TEST) { 5 | throw new Error( 6 | chalk.red( 7 | "Refusing to run outside of CI mode! WARNING: running the integration tests against your development server will cause test data to be inserted into your development database. To run these tests locally, use `yarn ci:test:integration` instead." 8 | ) 9 | ); 10 | } 11 | 12 | if ( 13 | !process.env.MAILING_DATABASE_URL?.match(/test$/) || 14 | !process.env.WEB_DATABASE_URL?.match(/test$/) 15 | ) 16 | throw new Error( 17 | `refusing to run against non-test databases process.env.MAILING_DATABASE_URL: ${process.env.MAILING_DATABASE_URL} process.env.WEB_DATABASE_URL: ${process.env.WEB_DATABASE_URL}` 18 | ); 19 | 20 | beforeAll(async () => { 21 | await truncateDatabases(); 22 | }); 23 | 24 | afterAll(async () => { 25 | await disconnectDatabases(); 26 | }); 27 | 28 | afterEach(() => { 29 | jest.resetAllMocks(); 30 | }); 31 | 32 | export default {}; 33 | -------------------------------------------------------------------------------- /testSetup.ts: -------------------------------------------------------------------------------- 1 | import "@testing-library/jest-dom"; 2 | 3 | jest.mock("./packages/cli/src/moduleManifest"); 4 | 5 | afterEach(() => { 6 | jest.resetAllMocks(); 7 | }); 8 | 9 | const testSetup = {}; 10 | export default testSetup; 11 | --------------------------------------------------------------------------------