This commit is contained in:
Harshith Mullapudi 2025-06-12 00:41:00 +05:30
parent a9034fb448
commit d111220aca
45 changed files with 5506 additions and 135 deletions

View File

@ -0,0 +1,35 @@
{
"compilerOptions": {
"target": "es2022",
"lib": ["ES2022", "DOM", "DOM.Iterable", "DOM.AsyncIterable"],
"module": "NodeNext",
"moduleResolution": "NodeNext",
"moduleDetection": "force",
"verbatimModuleSyntax": false,
"jsx": "react",
"strict": true,
"alwaysStrict": true,
"strictPropertyInitialization": false,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"noUnusedLocals": false,
"noUnusedParameters": false,
"noImplicitAny": true,
"noImplicitReturns": true,
"noImplicitThis": true,
"noFallthroughCasesInSwitch": true,
"resolveJsonModule": true,
"removeComments": false,
"esModuleInterop": true,
"emitDecoratorMetadata": false,
"experimentalDecorators": false,
"downlevelIteration": true,
"isolatedModules": true,
"noUncheckedIndexedAccess": true,
"pretty": true
}
}

View File

@ -5,7 +5,7 @@ import { Graph, type GraphRef } from "./graph";
import { GraphPopovers } from "./graph-popover";
import type { RawTriplet, NodePopupContent, EdgePopupContent } from "./type";
import { createLabelColorMap, getNodeColor } from "./node-colors";
import { createLabelColorMap } from "./node-colors";
import { useTheme } from "remix-themes";
import { toGraphTriplets } from "./utils";

View File

@ -0,0 +1,22 @@
import { cn } from "~/lib/utils";
export function FormButtons({
cancelButton,
confirmButton,
className,
}: {
cancelButton?: React.ReactNode;
confirmButton: React.ReactNode;
className?: string;
}) {
return (
<div
className={cn(
"border-grid-bright flex w-full items-center justify-between border-t pt-4",
className,
)}
>
{cancelButton ? cancelButton : <div />} {confirmButton}
</div>
);
}

View File

@ -0,0 +1,55 @@
import React, { type FunctionComponent, createElement } from "react";
import { cn } from "~/lib/utils";
export type RenderIcon =
| FunctionComponent<{ className?: string }>
| React.ReactNode;
export type IconProps = {
icon?: RenderIcon;
className?: string;
};
/** Use this icon to either render a passed in React component, or a NamedIcon/CompanyIcon */
export function Icon(props: IconProps) {
if (!props.icon) return null;
if (typeof props.icon === "function") {
const Icon = props.icon;
return <Icon className={props.className} />;
}
if (React.isValidElement(props.icon)) {
return <>{props.icon}</>;
}
if (
props.icon &&
typeof props.icon === "object" &&
("type" in props.icon || "$$typeof" in props.icon)
) {
return createElement<FunctionComponent<any>>(
props.icon as any,
{ className: props.className } as any,
);
}
console.error("Invalid icon", props);
return null;
}
export function IconInBox({
boxClassName,
...props
}: IconProps & { boxClassName?: string }) {
return (
<div
className={cn(
"border-charcoal-750 bg-charcoal-850 grid h-9 w-9 place-content-center rounded-sm border",
boxClassName,
)}
>
<Icon icon={props.icon} className={cn("h-6 w-6", props.className)} />
</div>
);
}

View File

@ -0,0 +1,57 @@
import { Link } from "@remix-run/react";
import { Icon, type RenderIcon } from "./Icon";
import { cn } from "~/lib/utils";
const variations = {
primary:
"text-indigo-500 transition hover:text-indigo-400 inline-flex gap-0.5 items-center group focus-visible:focus-custom",
secondary:
"text-text-dimmed transition hover:text-text-bright inline-flex gap-0.5 items-center group focus-visible:focus-custom",
} as const;
type TextLinkProps = {
href?: string;
to?: string;
className?: string;
trailingIcon?: RenderIcon;
trailingIconClassName?: string;
variant?: keyof typeof variations;
children: React.ReactNode;
} & React.AnchorHTMLAttributes<HTMLAnchorElement>;
export function TextLink({
href,
to,
children,
className,
trailingIcon,
trailingIconClassName,
variant = "primary",
...props
}: TextLinkProps) {
const classes = variations[variant];
return to ? (
<Link to={to} className={cn(classes, className)} {...props}>
{children}{" "}
{trailingIcon && (
<Icon
icon={trailingIcon}
className={cn("size-4", trailingIconClassName)}
/>
)}
</Link>
) : href ? (
<a href={href} className={cn(classes, className)} {...props}>
{children}{" "}
{trailingIcon && (
<Icon
icon={trailingIcon}
className={cn("size-4", trailingIconClassName)}
/>
)}
</a>
) : (
<span>Need to define a path or href</span>
);
}

View File

@ -1,12 +1,12 @@
import * as React from "react"
import * as React from "react";
import {
ChevronLeftIcon,
ChevronRightIcon,
MoreHorizontalIcon,
} from "lucide-react"
} from "lucide-react";
import { cn } from "~/lib/utils"
import { Button, buttonVariants } from "~/components/ui/button"
import { cn } from "~/lib/utils";
import { type Button, buttonVariants } from "~/components/ui/button";
function Pagination({ className, ...props }: React.ComponentProps<"nav">) {
return (
@ -17,7 +17,7 @@ function Pagination({ className, ...props }: React.ComponentProps<"nav">) {
className={cn("mx-auto flex w-full justify-center", className)}
{...props}
/>
)
);
}
function PaginationContent({
@ -30,17 +30,17 @@ function PaginationContent({
className={cn("flex flex-row items-center gap-1", className)}
{...props}
/>
)
);
}
function PaginationItem({ ...props }: React.ComponentProps<"li">) {
return <li data-slot="pagination-item" {...props} />
return <li data-slot="pagination-item" {...props} />;
}
type PaginationLinkProps = {
isActive?: boolean
isActive?: boolean;
} & Pick<React.ComponentProps<typeof Button>, "size"> &
React.ComponentProps<"a">
React.ComponentProps<"a">;
function PaginationLink({
className,
@ -58,11 +58,11 @@ function PaginationLink({
variant: isActive ? "outline" : "ghost",
size,
}),
className
className,
)}
{...props}
/>
)
);
}
function PaginationPrevious({
@ -79,7 +79,7 @@ function PaginationPrevious({
<ChevronLeftIcon />
<span className="hidden sm:block">Previous</span>
</PaginationLink>
)
);
}
function PaginationNext({
@ -96,7 +96,7 @@ function PaginationNext({
<span className="hidden sm:block">Next</span>
<ChevronRightIcon />
</PaginationLink>
)
);
}
function PaginationEllipsis({
@ -113,7 +113,7 @@ function PaginationEllipsis({
<MoreHorizontalIcon className="size-4" />
<span className="sr-only">More pages</span>
</span>
)
);
}
export {
@ -124,4 +124,4 @@ export {
PaginationPrevious,
PaginationNext,
PaginationEllipsis,
}
};

View File

@ -1,6 +1,6 @@
import * as React from "react";
import { Slot } from "@radix-ui/react-slot";
import { cva, VariantProps } from "class-variance-authority";
import { cva, type VariantProps } from "class-variance-authority";
import { PanelLeftIcon } from "lucide-react";
import { useIsMobile } from "~/hooks/use-mobile";

View File

@ -1,5 +1,6 @@
import { z } from "zod";
import { isValidDatabaseUrl } from "./utils/db";
import { isValidRegex } from "./utils/regex";
const EnvironmentSchema = z.object({
NODE_ENV: z.union([
@ -25,6 +26,15 @@ const EnvironmentSchema = z.object({
DATABASE_READ_REPLICA_URL: z.string().optional(),
SESSION_SECRET: z.string(),
ENCRYPTION_KEY: z.string(),
MAGIC_LINK_SECRET: z.string(),
WHITELISTED_EMAILS: z
.string()
.refine(isValidRegex, "WHITELISTED_EMAILS must be a valid regex.")
.optional(),
ADMIN_EMAILS: z
.string()
.refine(isValidRegex, "ADMIN_EMAILS must be a valid regex.")
.optional(),
APP_ENV: z.string().default(process.env.NODE_ENV),
LOGIN_ORIGIN: z.string().default("http://localhost:5173"),
@ -47,6 +57,16 @@ const EnvironmentSchema = z.object({
//OpenAI
OPENAI_API_KEY: z.string(),
EMAIL_TRANSPORT: z.enum(["resend", "smtp", "aws-ses"]).optional(),
FROM_EMAIL: z.string().optional(),
REPLY_TO_EMAIL: z.string().optional(),
RESEND_API_KEY: z.string().optional(),
SMTP_HOST: z.string().optional(),
SMTP_PORT: z.coerce.number().optional(),
SMTP_SECURE: z.coerce.boolean().optional(),
SMTP_USER: z.string().optional(),
SMTP_PASSWORD: z.string().optional(),
});
export type Environment = z.infer<typeof EnvironmentSchema>;

View File

@ -2,7 +2,7 @@ import { type Workspace } from "@core/database";
import { type UIMatch } from "@remix-run/react";
import { useTypedMatchesData } from "./useTypedMatchData";
import { loader } from "~/routes/home";
import { type loader } from "~/routes/home";
export function useOptionalWorkspace(
matches?: UIMatch[],

View File

@ -1,8 +1,14 @@
import type { Prisma, User } from "@core/database";
import type { GoogleProfile } from "@coji/remix-auth-google";
import { prisma } from "~/db.server";
import { env } from "~/env.server";
export type { User } from "@core/database";
type FindOrCreateMagicLink = {
authenticationMethod: "MAGIC_LINK";
email: string;
};
type FindOrCreateGoogle = {
authenticationMethod: "GOOGLE";
email: User["email"];
@ -10,7 +16,7 @@ type FindOrCreateGoogle = {
authenticationExtraParams: Record<string, unknown>;
};
type FindOrCreateUser = FindOrCreateGoogle;
type FindOrCreateUser = FindOrCreateMagicLink | FindOrCreateGoogle;
type LoggedInUser = {
user: User;
@ -20,7 +26,55 @@ type LoggedInUser = {
export async function findOrCreateUser(
input: FindOrCreateUser,
): Promise<LoggedInUser> {
return findOrCreateGoogleUser(input);
switch (input.authenticationMethod) {
case "GOOGLE": {
return findOrCreateGoogleUser(input);
}
case "MAGIC_LINK": {
return findOrCreateMagicLinkUser(input);
}
}
}
export async function findOrCreateMagicLinkUser(
input: FindOrCreateMagicLink,
): Promise<LoggedInUser> {
if (
env.WHITELISTED_EMAILS &&
!new RegExp(env.WHITELISTED_EMAILS).test(input.email)
) {
throw new Error("This email is unauthorized");
}
const existingUser = await prisma.user.findFirst({
where: {
email: input.email,
},
});
const adminEmailRegex = env.ADMIN_EMAILS
? new RegExp(env.ADMIN_EMAILS)
: undefined;
const makeAdmin = adminEmailRegex ? adminEmailRegex.test(input.email) : false;
const user = await prisma.user.upsert({
where: {
email: input.email,
},
update: {
email: input.email,
},
create: {
email: input.email,
authenticationMethod: "MAGIC_LINK",
admin: makeAdmin, // only on create, to prevent automatically removing existing admins
},
});
return {
user,
isNewUser: !existingUser,
};
}
export async function findOrCreateGoogleUser({

View File

@ -1,9 +1,4 @@
import {
redirect,
createCookie,
type ActionFunction,
type LoaderFunction,
} from "@remix-run/node";
import { createCookie, type LoaderFunction } from "@remix-run/node";
import { authenticator } from "~/services/auth.server";
export let loader: LoaderFunction = async ({ request }) => {

View File

@ -1,14 +1,7 @@
import {
type ActionFunctionArgs,
type LoaderFunctionArgs,
} from "@remix-run/server-runtime";
import {
requireUser,
requireUserId,
requireWorkpace,
} from "~/services/session.server";
import { type LoaderFunctionArgs } from "@remix-run/server-runtime";
import { requireUser, requireWorkpace } from "~/services/session.server";
import { Outlet, useActionData } from "@remix-run/react";
import { Outlet } from "@remix-run/react";
import { typedjson } from "remix-typedjson";
import { clearRedirectTo, commitSession } from "~/services/redirectTo.server";

View File

@ -8,6 +8,7 @@ import { setRedirectTo } from "~/services/redirectTo.server";
import { getUserId } from "~/services/session.server";
import { commitSession } from "~/services/sessionStorage.server";
import { requestUrl } from "~/utils/requestUrl.server";
import { env } from "~/env.server";
import { RiGoogleLine } from "@remixicon/react";
import {
@ -18,6 +19,7 @@ import {
CardTitle,
} from "~/components/ui/card";
import { Button } from "~/components/ui";
import { Mail } from "lucide-react";
export async function loader({ request }: LoaderFunctionArgs) {
const userId = await getUserId(request);
@ -30,7 +32,11 @@ export async function loader({ request }: LoaderFunctionArgs) {
const session = await setRedirectTo(request, redirectTo);
return typedjson(
{ redirectTo, showGoogleAuth: isGoogleAuthSupported },
{
redirectTo,
showGoogleAuth: isGoogleAuthSupported,
isDevelopment: env.NODE_ENV === "development",
},
{
headers: {
"Set-Cookie": await commitSession(session),
@ -41,6 +47,7 @@ export async function loader({ request }: LoaderFunctionArgs) {
return typedjson({
redirectTo: null,
showGoogleAuth: isGoogleAuthSupported,
isDevelopment: env.NODE_ENV === "development",
});
}
}
@ -72,6 +79,19 @@ export default function LoginPage() {
<span>Continue with Google</span>
</Button>
)}
{data.isDevelopment && (
<Button
variant="secondary"
size="lg"
data-action="continue with email"
className="text-text-bright"
onClick={() => (window.location.href = "/login/magic")}
>
<Mail className="text-text-bright mr-2 size-5" />
Continue with Email
</Button>
)}
</div>
</Fieldset>
</CardContent>

View File

@ -0,0 +1,212 @@
import {
redirect,
type ActionFunctionArgs,
type LoaderFunctionArgs,
type MetaFunction,
} from "@remix-run/node";
import { Form, useNavigation } from "@remix-run/react";
import { Inbox, Loader, Mail } from "lucide-react";
import { typedjson, useTypedLoaderData } from "remix-typedjson";
import { z } from "zod";
import { LoginPageLayout } from "~/components/layout/LoginPageLayout";
import { Button } from "~/components/ui";
import { Fieldset } from "~/components/ui/Fieldset";
import { FormButtons } from "~/components/ui/FormButtons";
import { Header1 } from "~/components/ui/Headers";
import { Input } from "~/components/ui/input";
import { Paragraph } from "~/components/ui/Paragraph";
import { TextLink } from "~/components/ui/TextLink";
import { authenticator } from "~/services/auth.server";
import { getUserId } from "~/services/session.server";
import {
commitSession,
getUserSession,
} from "~/services/sessionStorage.server";
import { env } from "~/env.server";
export const meta: MetaFunction = ({ matches }) => {
const parentMeta = matches
.flatMap((match) => match.meta ?? [])
.filter((meta) => {
if ("title" in meta) return false;
if ("name" in meta && meta.name === "viewport") return false;
return true;
});
return [
...parentMeta,
{ title: `Login to C.O.R.E.` },
{
name: "viewport",
content: "width=device-width,initial-scale=1",
},
];
};
export async function loader({ request }: LoaderFunctionArgs): Promise<any> {
if (env.NODE_ENV !== "development") {
return typedjson({ isDevelopment: false });
}
const userId = await getUserId(request);
if (userId) return redirect("/");
const session = await getUserSession(request);
const error = session.get("auth:error");
let magicLinkError: string | undefined | unknown;
if (error) {
if ("message" in error) {
magicLinkError = error.message;
} else {
magicLinkError = JSON.stringify(error, null, 2);
}
}
return typedjson(
{
isDevelopment: true,
magicLinkSent: session.has("core:magiclink"),
magicLinkError,
},
{
headers: { "Set-Cookie": await commitSession(session) },
},
);
}
export async function action({ request }: ActionFunctionArgs) {
if (env.NODE_ENV !== "development") {
throw new Error("Magic link login is only available in development mode");
}
const clonedRequest = request.clone();
const payload = Object.fromEntries(await clonedRequest.formData());
const { action } = z
.object({
action: z.enum(["send", "reset"]),
})
.parse(payload);
if (action === "send") {
return await authenticator.authenticate("email-link", request);
} else {
const session = await getUserSession(request);
session.unset("core:magiclink");
return redirect("/magic", {
headers: {
"Set-Cookie": await commitSession(session),
},
});
}
}
export default function LoginMagicLinkPage() {
const data = useTypedLoaderData<typeof loader>();
const navigate = useNavigation();
if (!data.isDevelopment) {
return (
<LoginPageLayout>
<Paragraph className="text-center">
Magic link login is only available in development mode.
</Paragraph>
</LoginPageLayout>
);
}
const isLoading =
(navigate.state === "loading" || navigate.state === "submitting") &&
navigate.formAction !== undefined &&
navigate.formData?.get("action") === "send";
return (
<LoginPageLayout>
<Form method="post">
<div className="flex flex-col items-center justify-center">
{data.magicLinkSent ? (
<>
<Header1 className="pb-6 text-center text-xl leading-7 font-normal md:text-xl lg:text-2xl">
We've sent you a magic link!
</Header1>
<Fieldset className="flex w-full flex-col items-center gap-y-2">
<Inbox className="text-primary mb-4 h-12 w-12" />
<Paragraph className="mb-6 text-center">
We sent you an email which contains a magic link that will log
you in to your account.
</Paragraph>
<FormButtons
cancelButton={
<Button
type="submit"
name="action"
value="reset"
variant="link"
data-action="re-enter email"
>
Re-enter email
</Button>
}
confirmButton={
<Button
variant="ghost"
data-action="log in using another option"
>
Log in using another option
</Button>
}
/>
</Fieldset>
</>
) : (
<>
<Header1 className="pb-4 font-semibold sm:text-2xl md:text-3xl lg:text-4xl">
Welcome
</Header1>
<Paragraph variant="base" className="mb-6 text-center">
Create an account or login using email
</Paragraph>
<Fieldset className="flex w-full flex-col items-center gap-y-2">
<Input
type="email"
name="email"
spellCheck={false}
placeholder="Email Address"
required
autoFocus
/>
<Button
name="action"
value="send"
type="submit"
variant="secondary"
size="lg"
disabled={isLoading}
data-action="send a magic link"
>
{isLoading ? (
<Loader className="mr-2 size-5" color="white" />
) : (
<Mail className="text-text-bright mr-2 size-5" />
)}
{isLoading ? (
<span className="text-text-bright">Sending</span>
) : (
<span className="text-text-bright">Send a magic link</span>
)}
</Button>
{data.magicLinkError && <>{data.magicLinkError}</>}
</Fieldset>
</>
)}
</div>
</Form>
</LoginPageLayout>
);
}

View File

@ -0,0 +1,23 @@
import { redirect } from "@remix-run/node";
import type { LoaderFunctionArgs } from "@remix-run/server-runtime";
import { authenticator } from "~/services/auth.server";
import { logger } from "~/services/logger.service";
import { saveSession } from "~/services/sessionStorage.server";
import { redirectCookie } from "./auth.google";
export async function loader({ request }: LoaderFunctionArgs) {
const cookie = request.headers.get("Cookie");
const redirectValue = await redirectCookie.parse(cookie);
const authuser = await authenticator.authenticate("email-link", request);
const redirectTo = redirectValue ?? "/";
const headers = await saveSession(request, authuser);
logger.debug("auth.google.callback authuser", {
authuser,
});
return redirect(redirectTo, {
headers,
});
}

View File

@ -1,69 +0,0 @@
import { env } from "~/env.server";
import { authenticateAuthorizationHeader } from "./apiAuth.server";
import { authorizationRateLimitMiddleware } from "./authorizationRateLimitMiddleware.server";
import { Duration } from "./rateLimiter.server";
export const apiRateLimiter = authorizationRateLimitMiddleware({
redis: {
port: env.RATE_LIMIT_REDIS_PORT,
host: env.RATE_LIMIT_REDIS_HOST,
username: env.RATE_LIMIT_REDIS_USERNAME,
password: env.RATE_LIMIT_REDIS_PASSWORD,
tlsDisabled: env.RATE_LIMIT_REDIS_TLS_DISABLED === "true",
clusterMode: env.RATE_LIMIT_REDIS_CLUSTER_MODE_ENABLED === "1",
},
keyPrefix: "api",
defaultLimiter: {
type: "tokenBucket",
refillRate: env.API_RATE_LIMIT_REFILL_RATE,
interval: env.API_RATE_LIMIT_REFILL_INTERVAL as Duration,
maxTokens: env.API_RATE_LIMIT_MAX,
},
limiterCache: {
fresh: 60_000 * 10, // Data is fresh for 10 minutes
stale: 60_000 * 20, // Date is stale after 20 minutes
},
limiterConfigOverride: async (authorizationValue) => {
const authenticatedEnv = await authenticateAuthorizationHeader(
authorizationValue,
{
allowPublicKey: true,
allowJWT: true,
},
);
if (!authenticatedEnv || !authenticatedEnv.ok) {
return;
}
if (authenticatedEnv.type === "PUBLIC_JWT") {
return {
type: "fixedWindow",
window: env.API_RATE_LIMIT_JWT_WINDOW,
tokens: env.API_RATE_LIMIT_JWT_TOKENS,
};
} else {
return authenticatedEnv.environment.organization.apiRateLimiterConfig;
}
},
pathMatchers: [/^\/api/],
// Allow /api/v1/tasks/:id/callback/:secret
pathWhiteList: [
"/api/internal/stripe_webhooks",
"/api/v1/authorization-code",
"/api/v1/token",
"/api/v1/usage/ingest",
"/api/v1/timezones",
"/api/v1/usage/ingest",
"/api/v1/auth/jwt/claims",
],
log: {
rejections: env.API_RATE_LIMIT_REJECTION_LOGS_ENABLED === "1",
requests: env.API_RATE_LIMIT_REQUEST_LOGS_ENABLED === "1",
limiter: env.API_RATE_LIMIT_LIMITER_LOGS_ENABLED === "1",
},
});
export type RateLimitMiddleware = ReturnType<
typeof authorizationRateLimitMiddleware
>;

View File

@ -1,9 +1,11 @@
import { Authenticator } from "remix-auth";
import type { AuthUser } from "./authUser";
import { addGoogleStrategy } from "./googleAuth.server";
import { env } from "~/env.server";
import { addEmailLinkStrategy } from "./emailAuth.server";
// Create an instance of the authenticator, pass a generic with what
// strategies will return and will store in the session
@ -21,4 +23,8 @@ if (env.AUTH_GOOGLE_CLIENT_ID && env.AUTH_GOOGLE_CLIENT_SECRET) {
);
}
if (env.NODE_ENV === "development") {
addEmailLinkStrategy(authenticator);
}
export { authenticator, isGoogleAuthSupported };

View File

@ -0,0 +1,95 @@
import {
type DeliverEmail,
type SendPlainTextOptions,
EmailClient,
type MailTransportOptions,
} from "emails";
import { redirect } from "remix-typedjson";
import { env } from "~/env.server";
import type { AuthUser } from "./authUser";
import { logger } from "./logger.service";
import { singleton } from "~/utils/singleton";
const client = singleton(
"email-client",
() =>
new EmailClient({
transport: buildTransportOptions(),
imagesBaseUrl: env.APP_ORIGIN,
from: env.FROM_EMAIL ?? "team@core.heysol.ai",
replyTo: env.REPLY_TO_EMAIL ?? "help@core.heysol.ai",
}),
);
function buildTransportOptions(): MailTransportOptions {
const transportType = env.EMAIL_TRANSPORT;
logger.debug(
`Constructing email transport '${transportType}' for usage general`,
);
switch (transportType) {
case "aws-ses":
return { type: "aws-ses" };
case "resend":
return {
type: "resend",
config: {
apiKey: env.RESEND_API_KEY,
},
};
case "smtp":
return {
type: "smtp",
config: {
host: env.SMTP_HOST,
port: env.SMTP_PORT,
secure: env.SMTP_SECURE,
auth: {
user: env.SMTP_USER,
pass: env.SMTP_PASSWORD,
},
},
};
default:
return { type: undefined };
}
}
export async function sendMagicLinkEmail(options: any): Promise<void> {
// Auto redirect when in development mode
if (env.NODE_ENV === "development") {
throw redirect(options.magicLink);
}
logger.debug("Sending magic link email", {
emailAddress: options.emailAddress,
});
try {
return await client.send({
email: "magic_link",
to: options.emailAddress,
magicLink: options.magicLink,
});
} catch (error) {
logger.error("Error sending magic link email", {
error: JSON.stringify(error),
});
throw error;
}
}
export async function sendPlainTextEmail(options: SendPlainTextOptions) {
return client.sendPlainText(options);
}
export async function scheduleEmail(
data: DeliverEmail,
delay?: { seconds: number },
) {}
export async function sendEmail(data: DeliverEmail) {
return client.send(data);
}

View File

@ -0,0 +1,43 @@
import { EmailLinkStrategy } from "@nichtsam/remix-auth-email-link";
import type { Authenticator } from "remix-auth";
import type { AuthUser } from "./authUser";
import { findOrCreateUser } from "~/models/user.server";
import { env } from "~/env.server";
import { sendMagicLinkEmail } from "~/services/email.server";
import { postAuthentication } from "./postAuth.server";
import { logger } from "./logger.service";
let secret = env.MAGIC_LINK_SECRET;
let APP_ORIGIN = env.APP_ORIGIN;
if (!secret) throw new Error("Missing MAGIC_LINK_SECRET env variable.");
const emailStrategy = new EmailLinkStrategy(
{
sendEmail: sendMagicLinkEmail,
secret,
magicEndpoint: `${APP_ORIGIN}/magic`,
},
async ({ email }: { email: string }) => {
logger.info("Magic link user authenticated", { email });
try {
const { user, isNewUser } = await findOrCreateUser({
email,
authenticationMethod: "MAGIC_LINK",
});
await postAuthentication({ user, isNewUser, loginMethod: "MAGIC_LINK" });
return { userId: user.id };
} catch (error) {
logger.debug("Magic link user failed to authenticate", {
error: JSON.stringify(error),
});
throw error;
}
},
);
export function addEmailLinkStrategy(authenticator: Authenticator<AuthUser>) {
authenticator.use(emailStrategy as any, "email-link");
}

View File

@ -57,6 +57,7 @@
"dayjs": "^1.11.10",
"date-fns": "^4.1.0",
"express": "^4.18.1",
"emails": "workspace:*",
"ioredis": "^5.6.1",
"isbot": "^4.1.0",
"jose": "^5.2.3",
@ -69,6 +70,7 @@
"react": "^18.2.0",
"react-dom": "^18.2.0",
"remix-auth": "^4.2.0",
"@nichtsam/remix-auth-email-link": "3.0.0",
"remix-auth-oauth2": "^3.4.1",
"remix-themes": "^1.3.1",
"remix-typedjson": "0.3.1",

74
docker/Dockerfile Normal file
View File

@ -0,0 +1,74 @@
ARG NODE_IMAGE=node:20.11.1-bullseye-slim@sha256:5a5a92b3a8d392691c983719dbdc65d9f30085d6dcd65376e7a32e6fe9bf4cbe
FROM ${NODE_IMAGE} AS pruner
WORKDIR /core
COPY --chown=node:node . .
RUN npx -q turbo@1.10.9 prune --scope=webapp --docker
RUN find . -name "node_modules" -type d -prune -exec rm -rf '{}' +
# Base strategy to have layer caching
FROM ${NODE_IMAGE} AS base
RUN apt-get update && apt-get install -y openssl dumb-init
WORKDIR /core
COPY --chown=node:node .gitignore .gitignore
COPY --from=pruner --chown=node:node /core/out/json/ .
COPY --from=pruner --chown=node:node /core/out/pnpm-lock.yaml ./pnpm-lock.yaml
COPY --from=pruner --chown=node:node /core/out/pnpm-workspace.yaml ./pnpm-workspace.yaml
## Dev deps
FROM base AS dev-deps
WORKDIR /core
# Corepack is used to install pnpm
RUN corepack enable
ENV NODE_ENV development
RUN pnpm install --ignore-scripts --no-frozen-lockfile
## Production deps
FROM base AS production-deps
WORKDIR /core
# Corepack is used to install pnpm
RUN corepack enable
ENV NODE_ENV production
RUN pnpm install --prod --no-frozen-lockfile
COPY --from=pruner --chown=node:node /core/packages/database/prisma/schema.prisma /core/packages/database/prisma/schema.prisma
# RUN pnpm add @prisma/client@5.1.1 -w
ENV NPM_CONFIG_IGNORE_WORKSPACE_ROOT_CHECK true
RUN pnpx prisma@5.4.1 generate --schema /core/packages/database/prisma/schema.prisma
## Builder (builds the webapp)
FROM base AS builder
WORKDIR /core
# Corepack is used to install pnpm
RUN corepack enable
COPY --from=pruner --chown=node:node /core/out/full/ .
COPY --from=dev-deps --chown=node:node /core/ .
COPY --chown=node:node turbo.json turbo.json
COPY --chown=node:node docker/scripts ./scripts
RUN chmod +x ./scripts/wait-for-it.sh
RUN chmod +x ./scripts/entrypoint.sh
COPY --chown=node:node .configs/tsconfig.base.json .configs/tsconfig.base.json
RUN pnpm run generate
RUN pnpm run build --filter=webapp...
# Runner
FROM ${NODE_IMAGE} AS runner
RUN apt-get update && apt-get install -y openssl netcat-openbsd ca-certificates
WORKDIR /core
RUN corepack enable
ENV NODE_ENV production
COPY --from=base /usr/bin/dumb-init /usr/bin/dumb-init
COPY --from=pruner --chown=node:node /core/out/full/ .
COPY --from=production-deps --chown=node:node /core .
COPY --from=builder --chown=node:node /core/apps/webapp/build/server.js ./apps/webapp/build/server.js
COPY --from=builder --chown=node:node /core/apps/webapp/build ./apps/webapp/build
COPY --from=builder --chown=node:node /core/apps/webapp/public ./apps/webapp/public
COPY --from=builder --chown=node:node /core/scripts ./scripts
EXPOSE 3000
USER node
CMD ["./scripts/entrypoint.sh"]

View File

18
docker/scripts/entrypoint.sh Executable file
View File

@ -0,0 +1,18 @@
#!/bin/sh
set -xe
if [ -n "$DATABASE_HOST" ]; then
scripts/wait-for-it.sh ${DATABASE_HOST} -- echo "database is up"
fi
# Run migrations
pnpm --filter @core/database db:migrate:deploy
# Copy over required prisma files
cp packages/database/prisma/schema.prisma apps/webapp/prisma/
cp node_modules/@prisma/engines/*.node apps/webapp/prisma/
cd /core/apps/webapp
# exec dumb-init pnpm run start:local
NODE_PATH='/core/node_modules/.pnpm/node_modules' exec dumb-init node --max-old-space-size=8192 ./build/server.js

184
docker/scripts/wait-for-it.sh Executable file
View File

@ -0,0 +1,184 @@
#!/bin/sh
# The MIT License (MIT)
#
# Copyright (c) 2017 Eficode Oy
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in all
# copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.
set -- "$@" -- "$TIMEOUT" "$QUIET" "$PROTOCOL" "$HOST" "$PORT" "$result"
TIMEOUT=15
QUIET=0
# The protocol to make the request with, either "tcp" or "http"
PROTOCOL="tcp"
echoerr() {
if [ "$QUIET" -ne 1 ]; then printf "%s\n" "$*" 1>&2; fi
}
usage() {
exitcode="$1"
cat << USAGE >&2
Usage:
$0 host:port|url [-t timeout] [-- command args]
-q | --quiet Do not output any status messages
-t TIMEOUT | --timeout=timeout Timeout in seconds, zero for no timeout
-- COMMAND ARGS Execute command with args after the test finishes
USAGE
exit "$exitcode"
}
wait_for() {
case "$PROTOCOL" in
tcp)
if ! command -v nc >/dev/null; then
echoerr 'nc command is missing!'
exit 1
fi
;;
wget)
if ! command -v wget >/dev/null; then
echoerr 'wget command is missing!'
exit 1
fi
;;
esac
while :; do
case "$PROTOCOL" in
tcp)
nc -w 1 -z "$HOST" "$PORT" > /dev/null 2>&1
;;
http)
wget --timeout=1 -q "$HOST" -O /dev/null > /dev/null 2>&1
;;
*)
echoerr "Unknown protocol '$PROTOCOL'"
exit 1
;;
esac
result=$?
if [ $result -eq 0 ] ; then
if [ $# -gt 7 ] ; then
for result in $(seq $(($# - 7))); do
result=$1
shift
set -- "$@" "$result"
done
TIMEOUT=$2 QUIET=$3 PROTOCOL=$4 HOST=$5 PORT=$6 result=$7
shift 7
exec "$@"
fi
exit 0
fi
if [ "$TIMEOUT" -le 0 ]; then
break
fi
TIMEOUT=$((TIMEOUT - 1))
sleep 1
done
echo "Operation timed out" >&2
exit 1
}
while :; do
case "$1" in
http://*|https://*)
HOST="$1"
PROTOCOL="http"
shift 1
;;
*:* )
HOST=$(printf "%s\n" "$1"| cut -d : -f 1)
PORT=$(printf "%s\n" "$1"| cut -d : -f 2)
shift 1
;;
-q | --quiet)
QUIET=1
shift 1
;;
-q-*)
QUIET=0
echoerr "Unknown option: $1"
usage 1
;;
-q*)
QUIET=1
result=$1
shift 1
set -- -"${result#-q}" "$@"
;;
-t | --timeout)
TIMEOUT="$2"
shift 2
;;
-t*)
TIMEOUT="${1#-t}"
shift 1
;;
--timeout=*)
TIMEOUT="${1#*=}"
shift 1
;;
--)
shift
break
;;
--help)
usage 0
;;
-*)
QUIET=0
echoerr "Unknown option: $1"
usage 1
;;
*)
QUIET=0
echoerr "Unknown argument: $1"
usage 1
;;
esac
done
if ! [ "$TIMEOUT" -ge 0 ] 2>/dev/null; then
echoerr "Error: invalid timeout '$TIMEOUT'"
usage 3
fi
case "$PROTOCOL" in
tcp)
if [ "$HOST" = "" ] || [ "$PORT" = "" ]; then
echoerr "Error: you need to provide a host and port to test."
usage 2
fi
;;
http)
if [ "$HOST" = "" ]; then
echoerr "Error: you need to provide a host to test."
usage 2
fi
;;
esac
wait_for "$@"

View File

@ -0,0 +1,2 @@
-- AlterEnum
ALTER TYPE "AuthenticationMethod" ADD VALUE 'MAGIC_LINK';

View File

@ -61,6 +61,7 @@ model Workspace {
enum AuthenticationMethod {
GOOGLE
MAGIC_LINK
}
/// Used to generate PersonalAccessTokens, they're one-time use

1
packages/emails/.gitignore vendored Normal file
View File

@ -0,0 +1 @@
.react-email

17
packages/emails/README.md Normal file
View File

@ -0,0 +1,17 @@
# Emails
## Getting Started
1. First, install the dependencies:
```sh
pnpm install --filter emails
```
2. Then, run the development server:
```sh
pnpm run dev --filter emails
```
Open [localhost:3080](http://localhost:3080) with your browser to see the result.

View File

@ -0,0 +1,10 @@
// Use a global variable to store the base path
let globalBasePath: string = "http://localhost:3000";
export function setGlobalBasePath(basePath: string) {
globalBasePath = basePath;
}
export function getGlobalBasePath() {
return globalBasePath;
}

View File

@ -0,0 +1,17 @@
import { Hr, Link, Text } from "@react-email/components";
import React from "react";
import { footer, footerAnchor, hr } from "./styles";
export function Footer() {
return (
<>
<Hr style={hr} />
<Text style={footer}>
©Sol.ai
<Link style={footerAnchor} href="https://core.heysol.dev/">
C.O.R.E
</Link>
</Text>
</>
);
}

View File

@ -0,0 +1,13 @@
import { Img } from "@react-email/components";
import * as React from "react";
import { getGlobalBasePath } from "./BasePath";
type ImageProps = Omit<Parameters<typeof Img>[0], "src"> & {
path: string;
};
export function Image({ path, ...props }: ImageProps) {
const basePath = getGlobalBasePath();
return <Img src={`${basePath}${path}`} {...props} />;
}

View File

@ -0,0 +1,113 @@
export const h1 = {
color: "#D7D9DD",
fontFamily:
"-apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif",
fontSize: "24px",
fontWeight: "bold",
margin: "40px 0",
padding: "0",
};
export const main = {
backgroundColor: "#15171A",
padding: "0 20px",
};
export const container = {
backgroundColor: "#15171A",
margin: "0 auto",
padding: "20px 0 48px",
marginBottom: "64px",
};
export const box = {
padding: "0 48px",
};
export const hr = {
borderColor: "#272A2E",
margin: "20px 0",
};
export const paragraph = {
color: "#878C99",
fontFamily:
'-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Ubuntu,sans-serif',
fontSize: "16px",
lineHeight: "24px",
textAlign: "left" as const,
};
export const paragraphLight = {
color: "#D7D9DD",
fontFamily:
'-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Ubuntu,sans-serif',
fontSize: "16px",
lineHeight: "24px",
textAlign: "left" as const,
};
export const paragraphTight = {
color: "#D7D9DD",
fontFamily:
'-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Ubuntu,sans-serif',
fontSize: "16px",
lineHeight: "16px",
textAlign: "left" as const,
};
export const bullets = {
color: "#D7D9DD",
fontFamily:
'-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Ubuntu,sans-serif',
fontSize: "16px",
lineHeight: "24px",
textAlign: "left" as const,
margin: "0",
};
export const anchor = {
color: "#826DFF",
fontFamily:
"-apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif",
fontSize: "16px",
textDecoration: "underline",
};
export const button = {
backgroundColor: "#826DFF",
borderRadius: "5px",
color: "#D7D9DD",
fontFamily:
'-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Ubuntu,sans-serif',
fontSize: "16px",
fontWeight: "bold",
textDecoration: "none",
textAlign: "center" as const,
display: "block",
};
export const footer = {
color: "#878C99",
fontFamily:
'-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Ubuntu,sans-serif',
fontSize: "12px",
lineHeight: "16px",
};
export const footerItalic = {
color: "#878C99",
fontStyle: "italic",
fontFamily:
'-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Ubuntu,sans-serif',
fontSize: "12px",
lineHeight: "16px",
};
export const footerAnchor = {
color: "#878C99",
fontFamily:
"-apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif",
fontSize: "12px",
textDecoration: "underline",
};

View File

@ -0,0 +1,49 @@
import { Body, Container, Head, Html, Link, Preview, Text } from "@react-email/components";
import { z } from "zod";
import { Footer } from "./components/Footer";
import { Image } from "./components/Image";
import { anchor, container, h1, main, paragraphLight } from "./components/styles";
export const InviteEmailSchema = z.object({
email: z.literal("invite"),
orgName: z.string(),
inviterName: z.string().optional(),
inviterEmail: z.string(),
inviteLink: z.string().url(),
});
export default function Email({
orgName,
inviterName,
inviterEmail,
inviteLink,
}: z.infer<typeof InviteEmailSchema>) {
return (
<Html>
<Head />
<Preview>{`You've been invited to ${orgName}`}</Preview>
<Body style={main}>
<Container style={container}>
<Text style={h1}>{`You've been invited to ${orgName}`}</Text>
<Text style={paragraphLight}>
{inviterName ?? inviterEmail} has invited you to join their organization on Sol.ai.
</Text>
<Link
href={inviteLink}
target="_blank"
style={{
...anchor,
display: "block",
marginBottom: "50px",
}}
>
Click here to view the invitation
</Link>
<Image path="/emails/logo-mono.png" width="120" height="22" alt="Sol.ai" />
<Footer />
</Container>
</Body>
</Html>
);
}

View File

@ -0,0 +1,39 @@
import { Body, Container, Head, Html, Link, Preview, Text } from "@react-email/components";
import { Footer } from "./components/Footer";
import { Image } from "./components/Image";
import { anchor, container, h1, main, paragraphLight } from "./components/styles";
export default function Email({ magicLink }: { magicLink: string }) {
return (
<Html>
<Head />
<Preview>Log in with this magic link 🪄</Preview>
<Body style={main}>
<Container style={container}>
<Text style={h1}>Log in to C.O.R.E.</Text>
<Link
href={magicLink}
target="_blank"
style={{
...anchor,
display: "block",
}}
>
Click here to log in with this magic link
</Link>
<Text
style={{
...paragraphLight,
display: "block",
marginBottom: "50px",
}}
>
If you didn&apos;t try to log in, you can safely ignore this email.
</Text>
<Image path="/emails/logo-mono.png" width="120" height="22" alt="core.heysol.ai" />
<Footer />
</Container>
</Body>
</Html>
);
}

View File

@ -0,0 +1,53 @@
import { Body, Head, Html, Link, Preview, Section, Text } from "@react-email/components";
import { Footer } from "./components/Footer";
import { anchor, bullets, footerItalic, main, paragraphLight } from "./components/styles";
export default function Email({ name }: { name?: string }) {
return (
<Html>
<Head />
<Preview>Welcome to C.O.R.E. - Your Personal AI Assistant</Preview>
<Body style={main}>
<Text style={paragraphLight}>Hey {name ?? "there"},</Text>
<Text style={paragraphLight}>Welcome to C.O.R.E., your new personal AI assistant!</Text>
<Text style={paragraphLight}>
I'm excited to help you streamline your daily tasks, boost your productivity, and make
your work life easier. C.O.R.E. is designed to be intuitive and powerful, adapting to your
unique needs and preferences.
</Text>
<Text style={paragraphLight}>
To get started, you can{" "}
<Link style={anchor} href="https://core.heysol.ai/dashboard">
visit your dashboard
</Link>{" "}
where you'll find all the features and capabilities at your disposal. Whether it's
managing your schedule, handling communications, or automating repetitive tasks, I'm here
to help.
</Text>
<Text style={paragraphLight}>
If you have any questions or need assistance, don't hesitate to reach out. You can:{"\n"}
Ask me directly through the chat interface{"\n"}{" "}
<Link style={anchor} href="https://core.heysol.ai/support">
Visit our support center
</Link>
{"\n"} Join our{" "}
<Link style={anchor} href="https://discord.gg/heysol">
Discord community
</Link>{" "}
to connect with other users and our team
</Text>
<Text style={paragraphLight}>Looking forward to being your trusted assistant!</Text>
<Text style={bullets}>Best regards,</Text>
<Text style={bullets}>C.O.R.E.</Text>
<Text style={paragraphLight}>Your AI Assistant</Text>
<Text style={footerItalic}>
You can customize your notification preferences anytime in your account settings.
</Text>
<Footer />
</Body>
</Html>
);
}

View File

@ -0,0 +1,29 @@
{
"private": true,
"name": "emails",
"version": "1.0.0",
"description": "Send emails",
"main": "./src/index.tsx",
"types": "./src/index.tsx",
"scripts": {
"dev": "PORT=3080 email dev"
},
"dependencies": {
"@aws-sdk/client-ses": "^3.716.0",
"@react-email/components": "0.0.16",
"@react-email/render": "^0.0.12",
"nodemailer": "^6.9.16",
"react": "^18.2.0",
"react-email": "^2.1.1",
"resend": "^3.2.0",
"tiny-invariant": "^1.2.0",
"zod": "3.23.8"
},
"devDependencies": {
"@types/nodemailer": "^6.4.17",
"@types/react": "18.2.69"
},
"engines": {
"node": ">=18.0.0"
}
}

View File

@ -0,0 +1,96 @@
import { ReactElement } from "react";
import { z } from "zod";
import { setGlobalBasePath } from "../emails/components/BasePath";
import InviteEmail, { InviteEmailSchema } from "../emails/invite";
import MagicLinkEmail from "../emails/magic-link";
import WelcomeEmail from "../emails/welcome";
import { constructMailTransport, MailTransport, MailTransportOptions } from "./transports";
export { type MailTransportOptions };
export const DeliverEmailSchema = z
.discriminatedUnion("email", [
z.object({
email: z.literal("magic_link"),
magicLink: z.string().url(),
}),
InviteEmailSchema,
])
.and(z.object({ to: z.string() }));
export type DeliverEmail = z.infer<typeof DeliverEmailSchema>;
export type SendPlainTextOptions = { to: string; subject: string; text: string };
export class EmailClient {
#transport: MailTransport;
#imagesBaseUrl: string;
#from: string;
#replyTo: string;
constructor(config: {
transport?: MailTransportOptions;
imagesBaseUrl: string;
from: string;
replyTo: string;
}) {
this.#transport = constructMailTransport(config.transport ?? { type: undefined });
this.#imagesBaseUrl = config.imagesBaseUrl;
this.#from = config.from;
this.#replyTo = config.replyTo;
}
async send(data: DeliverEmail) {
const { subject, component } = this.#getTemplate(data);
setGlobalBasePath(this.#imagesBaseUrl);
return await this.#transport.send({
to: data.to,
subject,
react: component,
from: this.#from,
replyTo: this.#replyTo,
});
}
async sendPlainText(options: SendPlainTextOptions) {
await this.#transport.sendPlainText({
...options,
from: this.#from,
replyTo: this.#replyTo,
});
}
#getTemplate(data: DeliverEmail): {
subject: string;
component: ReactElement;
} {
switch (data.email) {
case "magic_link":
return {
subject: "Magic sign-in link for C.O.R.E.",
component: <MagicLinkEmail magicLink={data.magicLink} />,
};
case "invite":
return {
subject: `You've been invited to join ${data.orgName} on C.O.R.E.`,
component: <InviteEmail {...data} />,
};
}
}
}
function formatErrorMessageForSubject(message?: string) {
if (!message) {
return "";
}
const singleLine = message.replace(/[\r\n]+/g, " ");
return singleLine.length > 30 ? singleLine.substring(0, 27) + "..." : singleLine;
}

View File

@ -0,0 +1,67 @@
import { render } from "@react-email/render";
import { EmailError, MailMessage, MailTransport, PlainTextMailMessage } from "./index";
import nodemailer from "nodemailer"
import * as awsSes from "@aws-sdk/client-ses"
export type AwsSesMailTransportOptions = {
type: 'aws-ses',
}
export class AwsSesMailTransport implements MailTransport {
#client: nodemailer.Transporter;
constructor(options: AwsSesMailTransportOptions) {
const ses = new awsSes.SESClient()
this.#client = nodemailer.createTransport({
SES: {
aws: awsSes,
ses
}
})
}
async send({to, from, replyTo, subject, react}: MailMessage): Promise<void> {
try {
await this.#client.sendMail({
from: from,
to,
replyTo: replyTo,
subject,
html: render(react),
});
}
catch (error) {
if (error instanceof Error) {
console.error(
`Failed to send email to ${to}, ${subject}. Error ${error.name}: ${error.message}`
);
throw new EmailError(error);
} else {
throw error;
}
}
}
async sendPlainText({to, from, replyTo, subject, text}: PlainTextMailMessage): Promise<void> {
try {
await this.#client.sendMail({
from: from,
to,
replyTo: replyTo,
subject,
text: text,
});
}
catch (error) {
if (error instanceof Error) {
console.error(
`Failed to send email to ${to}, ${subject}. Error ${error.name}: ${error.message}`
);
throw new EmailError(error);
} else {
throw error;
}
}
}
}

View File

@ -0,0 +1,52 @@
import { ReactElement } from "react";
import { AwsSesMailTransport, AwsSesMailTransportOptions } from "./aws-ses";
import { NullMailTransport, NullMailTransportOptions } from "./null";
import { ResendMailTransport, ResendMailTransportOptions } from "./resend";
import { SmtpMailTransport, SmtpMailTransportOptions } from "./smtp";
export type MailMessage = {
to: string;
from: string;
replyTo: string;
subject: string;
react: ReactElement;
};
export type PlainTextMailMessage = {
to: string;
from: string;
replyTo: string;
subject: string;
text: string;
}
export interface MailTransport {
send(message: MailMessage): Promise<void>;
sendPlainText(message: PlainTextMailMessage): Promise<void>;
}
export class EmailError extends Error {
constructor({ name, message }: { name: string; message: string }) {
super(message);
this.name = name;
}
}
export type MailTransportOptions =
AwsSesMailTransportOptions |
ResendMailTransportOptions |
NullMailTransportOptions |
SmtpMailTransportOptions
export function constructMailTransport(options: MailTransportOptions): MailTransport {
switch(options.type) {
case "aws-ses":
return new AwsSesMailTransport(options);
case "resend":
return new ResendMailTransport(options);
case "smtp":
return new SmtpMailTransport(options);
case undefined:
return new NullMailTransport(options);
}
}

View File

@ -0,0 +1,29 @@
import { render } from "@react-email/render";
import { MailMessage, MailTransport, PlainTextMailMessage } from "./index";
export type NullMailTransportOptions = {
type: undefined,
}
export class NullMailTransport implements MailTransport {
constructor(options: NullMailTransportOptions) {
}
async send({to, subject, react}: MailMessage): Promise<void> {
console.log(`
##### sendEmail to ${to}, subject: ${subject}
${render(react, {
plainText: true,
})}
`);
}
async sendPlainText({to, subject, text}: PlainTextMailMessage): Promise<void> {
console.log(`
##### sendEmail to ${to}, subject: ${subject}
${text}
`);
}
}

View File

@ -0,0 +1,51 @@
import { EmailError, MailMessage, MailTransport, PlainTextMailMessage } from "./index";
import { Resend } from "resend";
export type ResendMailTransportOptions = {
type: 'resend',
config: {
apiKey?: string
}
}
export class ResendMailTransport implements MailTransport {
#client: Resend;
constructor(options: ResendMailTransportOptions) {
this.#client = new Resend(options.config.apiKey)
}
async send({to, from, replyTo, subject, react}: MailMessage): Promise<void> {
const result = await this.#client.emails.send({
from: from,
to,
reply_to: replyTo,
subject,
react,
});
if (result.error) {
console.error(
`Failed to send email to ${to}, ${subject}. Error ${result.error.name}: ${result.error.message}`
);
throw new EmailError(result.error);
}
}
async sendPlainText({to, from, replyTo, subject, text}: PlainTextMailMessage): Promise<void> {
const result = await this.#client.emails.send({
from: from,
to,
reply_to: replyTo,
subject,
text,
});
if (result.error) {
console.error(
`Failed to send email to ${to}, ${subject}. Error ${result.error.name}: ${result.error.message}`
);
throw new EmailError(result.error);
}
}
}

View File

@ -0,0 +1,66 @@
import { render } from "@react-email/render";
import nodemailer from "nodemailer";
import { EmailError, MailMessage, MailTransport, PlainTextMailMessage } from "./index";
export type SmtpMailTransportOptions = {
type: "smtp";
config: {
host?: string;
port?: number;
secure?: boolean;
auth?: {
user?: string;
pass?: string;
};
};
};
export class SmtpMailTransport implements MailTransport {
#client: nodemailer.Transporter;
constructor(options: SmtpMailTransportOptions) {
this.#client = nodemailer.createTransport(options.config);
}
async send({ to, from, replyTo, subject, react }: MailMessage): Promise<void> {
try {
await this.#client.sendMail({
from: from,
to,
replyTo: replyTo,
subject,
html: render(react),
});
} catch (error) {
if (error instanceof Error) {
console.error(
`Failed to send email to ${to}, ${subject}. Error ${error.name}: ${error.message}`
);
throw new EmailError(error);
} else {
throw error;
}
}
}
async sendPlainText({ to, from, replyTo, subject, text }: PlainTextMailMessage): Promise<void> {
try {
await this.#client.sendMail({
from: from,
to,
replyTo: replyTo,
subject,
text: text,
});
} catch (error) {
if (error instanceof Error) {
console.error(
`Failed to send email to ${to}, ${subject}. Error ${error.name}: ${error.message}`
);
throw new EmailError(error);
} else {
throw error;
}
}
}
}

View File

@ -0,0 +1,20 @@
{
"compilerOptions": {
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"noEmit": true,
"incremental": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "node",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"target": "ES2015",
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"],
"exclude": ["node_modules"]
}

3862
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@ -56,6 +56,7 @@
"NEO4J_URI",
"NEO4J_USERNAME",
"NEO4J_PASSWORD",
"OPENAI_API_KEY"
"OPENAI_API_KEY",
"MAGIC_LINK_SECRET"
]
}