chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1 @@
|
||||
.react-email
|
||||
@@ -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.
|
||||
@@ -0,0 +1,98 @@
|
||||
import {
|
||||
Body,
|
||||
CodeBlock,
|
||||
Container,
|
||||
Head,
|
||||
Html,
|
||||
Link,
|
||||
Preview,
|
||||
Text,
|
||||
dracula,
|
||||
} from "@react-email/components";
|
||||
import { z } from "zod";
|
||||
import { Footer } from "./components/Footer";
|
||||
import { Image } from "./components/Image";
|
||||
import { anchor, container, h1, main, paragraphLight, paragraphTight } from "./components/styles";
|
||||
|
||||
export const AlertAttemptEmailSchema = z.object({
|
||||
email: z.literal("alert-attempt"),
|
||||
taskIdentifier: z.string(),
|
||||
fileName: z.string(),
|
||||
exportName: z.string(),
|
||||
version: z.string(),
|
||||
environment: z.string(),
|
||||
error: z.object({
|
||||
message: z.string(),
|
||||
name: z.string().optional(),
|
||||
stackTrace: z.string().optional(),
|
||||
}),
|
||||
attemptLink: z.string().url(),
|
||||
organization: z.string(),
|
||||
});
|
||||
|
||||
const previewDefaults = {
|
||||
taskIdentifier: "my-task",
|
||||
fileName: "other.ts",
|
||||
exportName: "myTask",
|
||||
version: "20240101.1",
|
||||
environment: "prod",
|
||||
error: {
|
||||
message: "Error message",
|
||||
name: "Error name",
|
||||
stackTrace: "Error stack trace",
|
||||
},
|
||||
attemptLink: "https://trigger.dev",
|
||||
};
|
||||
|
||||
export default function Email(props: z.infer<typeof AlertAttemptEmailSchema>) {
|
||||
const {
|
||||
taskIdentifier,
|
||||
fileName,
|
||||
exportName,
|
||||
version,
|
||||
environment,
|
||||
error,
|
||||
attemptLink,
|
||||
organization,
|
||||
} = {
|
||||
...previewDefaults,
|
||||
...props,
|
||||
};
|
||||
|
||||
return (
|
||||
<Html>
|
||||
<Head />
|
||||
<Preview>{`${organization}: [${version}.${environment} ${taskIdentifier}] ${error.message}`}</Preview>
|
||||
<Body style={main}>
|
||||
<Container style={container}>
|
||||
<Text style={h1}>There's been an error on `{taskIdentifier}`</Text>
|
||||
<Text style={paragraphTight}>Task ID: {taskIdentifier}</Text>
|
||||
<Text style={paragraphTight}>Filename: {fileName}</Text>
|
||||
<Text style={paragraphTight}>Function: {exportName}()</Text>
|
||||
<Text style={paragraphTight}>Version: {version}</Text>
|
||||
<Text style={paragraphTight}>Environment: {environment}</Text>
|
||||
<Text style={paragraphTight}>Organization: {organization}</Text>
|
||||
|
||||
<Text style={paragraphLight}>{error.message}</Text>
|
||||
{error.stackTrace && (
|
||||
<CodeBlock code={error.stackTrace} theme={dracula} lineNumbers language="log" />
|
||||
)}
|
||||
<Link
|
||||
href={attemptLink}
|
||||
target="_blank"
|
||||
style={{
|
||||
...anchor,
|
||||
display: "block",
|
||||
marginBottom: "50px",
|
||||
}}
|
||||
>
|
||||
Investigate this error
|
||||
</Link>
|
||||
|
||||
<Image path="/emails/logo-mono.png" width="120" height="22" alt="Trigger.dev" />
|
||||
<Footer />
|
||||
</Container>
|
||||
</Body>
|
||||
</Html>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
import {
|
||||
Body,
|
||||
CodeBlock,
|
||||
Container,
|
||||
Head,
|
||||
Html,
|
||||
Link,
|
||||
Preview,
|
||||
Text,
|
||||
dracula,
|
||||
} from "@react-email/components";
|
||||
import { z } from "zod";
|
||||
import { Footer } from "./components/Footer";
|
||||
import { Image } from "./components/Image";
|
||||
import { anchor, container, h1, main, paragraphLight, paragraphTight } from "./components/styles";
|
||||
import React from "react";
|
||||
|
||||
export const AlertErrorGroupEmailSchema = z.object({
|
||||
email: z.literal("alert-error-group"),
|
||||
classification: z.enum(["new_issue", "regression", "unignored"]),
|
||||
taskIdentifier: z.string(),
|
||||
environment: z.string(),
|
||||
error: z.object({
|
||||
message: z.string(),
|
||||
type: z.string().optional(),
|
||||
stackTrace: z.string().optional(),
|
||||
}),
|
||||
occurrenceCount: z.number(),
|
||||
errorLink: z.string().url(),
|
||||
organization: z.string(),
|
||||
project: z.string(),
|
||||
});
|
||||
|
||||
type AlertErrorGroupEmailProps = z.infer<typeof AlertErrorGroupEmailSchema>;
|
||||
|
||||
const classificationLabels: Record<string, string> = {
|
||||
new_issue: "New error",
|
||||
regression: "Regression",
|
||||
unignored: "Error resurfaced",
|
||||
};
|
||||
|
||||
const previewDefaults: AlertErrorGroupEmailProps = {
|
||||
email: "alert-error-group",
|
||||
classification: "new_issue",
|
||||
taskIdentifier: "my-task",
|
||||
environment: "Production",
|
||||
error: {
|
||||
message: "Cannot read property 'foo' of undefined",
|
||||
type: "TypeError",
|
||||
stackTrace: "TypeError: Cannot read property 'foo' of undefined\n at Object.<anonymous>",
|
||||
},
|
||||
occurrenceCount: 42,
|
||||
errorLink: "https://trigger.dev",
|
||||
organization: "my-organization",
|
||||
project: "my-project",
|
||||
};
|
||||
|
||||
export default function Email(props: AlertErrorGroupEmailProps) {
|
||||
const {
|
||||
classification,
|
||||
taskIdentifier,
|
||||
environment,
|
||||
error,
|
||||
occurrenceCount,
|
||||
errorLink,
|
||||
organization,
|
||||
project,
|
||||
} = {
|
||||
...previewDefaults,
|
||||
...props,
|
||||
};
|
||||
|
||||
const label = classificationLabels[classification] ?? "Error alert";
|
||||
|
||||
return (
|
||||
<Html>
|
||||
<Head />
|
||||
<Preview>
|
||||
{`${organization}: [${label}] ${error.type ?? "Error"} in ${taskIdentifier} (${environment})`}
|
||||
</Preview>
|
||||
<Body style={main}>
|
||||
<Container style={container}>
|
||||
<Text style={h1}>
|
||||
{label}: {error.type ?? "Error"} in {taskIdentifier}
|
||||
</Text>
|
||||
<Text style={paragraphTight}>Organization: {organization}</Text>
|
||||
<Text style={paragraphTight}>Project: {project}</Text>
|
||||
<Text style={paragraphTight}>Task: {taskIdentifier}</Text>
|
||||
<Text style={paragraphTight}>Environment: {environment}</Text>
|
||||
<Text style={paragraphTight}>Occurrences: {occurrenceCount}</Text>
|
||||
|
||||
<Text style={paragraphLight}>{error.message}</Text>
|
||||
{error.stackTrace && (
|
||||
<CodeBlock code={error.stackTrace} theme={dracula} lineNumbers language="log" />
|
||||
)}
|
||||
<Link
|
||||
href={errorLink}
|
||||
target="_blank"
|
||||
style={{
|
||||
...anchor,
|
||||
display: "block",
|
||||
marginBottom: "50px",
|
||||
}}
|
||||
>
|
||||
Investigate this error
|
||||
</Link>
|
||||
|
||||
<Image path="/emails/logo-mono.png" width="120" height="22" alt="Trigger.dev" />
|
||||
<Footer />
|
||||
</Container>
|
||||
</Body>
|
||||
</Html>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import {
|
||||
Body,
|
||||
CodeBlock,
|
||||
Container,
|
||||
Head,
|
||||
Html,
|
||||
Link,
|
||||
Preview,
|
||||
Text,
|
||||
dracula,
|
||||
} from "@react-email/components";
|
||||
import { z } from "zod";
|
||||
import { Footer } from "./components/Footer";
|
||||
import { Image } from "./components/Image";
|
||||
import { anchor, container, h1, main, paragraphLight, paragraphTight } from "./components/styles";
|
||||
import React from "react";
|
||||
|
||||
export const AlertRunEmailSchema = z.object({
|
||||
email: z.literal("alert-run"),
|
||||
runId: z.string(),
|
||||
project: z.string(),
|
||||
taskIdentifier: z.string(),
|
||||
fileName: z.string(),
|
||||
version: z.string(),
|
||||
environment: z.string(),
|
||||
error: z.object({
|
||||
message: z.string(),
|
||||
name: z.string().optional(),
|
||||
stackTrace: z.string().optional(),
|
||||
}),
|
||||
runLink: z.string().url(),
|
||||
organization: z.string(),
|
||||
});
|
||||
|
||||
type AlertRunEmailProps = z.infer<typeof AlertRunEmailSchema>;
|
||||
|
||||
const previewDefaults: AlertRunEmailProps = {
|
||||
email: "alert-run",
|
||||
runId: "run_12345678",
|
||||
project: "my-project",
|
||||
taskIdentifier: "my-task",
|
||||
fileName: "other.ts",
|
||||
version: "20240101.1",
|
||||
environment: "prod",
|
||||
error: {
|
||||
message: "Error message",
|
||||
name: "Error name",
|
||||
stackTrace: "Error stack trace",
|
||||
},
|
||||
runLink: "https://trigger.dev",
|
||||
organization: "my-organization",
|
||||
};
|
||||
|
||||
export default function Email(props: AlertRunEmailProps) {
|
||||
const {
|
||||
runId,
|
||||
project,
|
||||
taskIdentifier,
|
||||
fileName,
|
||||
version,
|
||||
environment,
|
||||
error,
|
||||
runLink,
|
||||
organization,
|
||||
} = {
|
||||
...previewDefaults,
|
||||
...props,
|
||||
};
|
||||
|
||||
return (
|
||||
<Html>
|
||||
<Head />
|
||||
<Preview>{`${organization}: [${version}.${environment} ${taskIdentifier}] ${error.message}`}</Preview>
|
||||
<Body style={main}>
|
||||
<Container style={container}>
|
||||
<Text style={h1}>Run `{runId}` failed</Text>
|
||||
<Text style={paragraphTight}>Organization: {organization}</Text>
|
||||
<Text style={paragraphTight}>Project: {project}</Text>
|
||||
<Text style={paragraphTight}>Task ID: {taskIdentifier}</Text>
|
||||
<Text style={paragraphTight}>Filename: {fileName}</Text>
|
||||
<Text style={paragraphTight}>Version: {version}</Text>
|
||||
<Text style={paragraphTight}>Environment: {environment}</Text>
|
||||
|
||||
<Text style={paragraphLight}>{error.message}</Text>
|
||||
{error.stackTrace && (
|
||||
<CodeBlock code={error.stackTrace} theme={dracula} lineNumbers language="log" />
|
||||
)}
|
||||
<Link
|
||||
href={runLink}
|
||||
target="_blank"
|
||||
style={{
|
||||
...anchor,
|
||||
display: "block",
|
||||
marginBottom: "50px",
|
||||
}}
|
||||
>
|
||||
Investigate this error
|
||||
</Link>
|
||||
|
||||
<Image path="/emails/logo-mono.png" width="120" height="22" alt="Trigger.dev" />
|
||||
<Footer />
|
||||
</Container>
|
||||
</Body>
|
||||
</Html>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
import {
|
||||
Body,
|
||||
Button,
|
||||
Column,
|
||||
Container,
|
||||
Head,
|
||||
Html,
|
||||
Preview,
|
||||
Row,
|
||||
Text,
|
||||
} from "@react-email/components";
|
||||
import { z } from "zod";
|
||||
import { Footer } from "./components/Footer";
|
||||
import { Image } from "./components/Image";
|
||||
import { bullets, container, grey, h1, main, paragraphLight, sans } from "./components/styles";
|
||||
|
||||
export const BulkActionCompletedEmailSchema = z.object({
|
||||
email: z.literal("bulk-action-completed"),
|
||||
bulkActionId: z.string(),
|
||||
url: z.string().url(),
|
||||
totalCount: z.number(),
|
||||
successCount: z.number(),
|
||||
failureCount: z.number(),
|
||||
createdAt: z.string(),
|
||||
completedAt: z.string(),
|
||||
type: z.enum(["CANCEL", "REPLAY"]),
|
||||
});
|
||||
|
||||
type BulkActionCompletedEmailProps = z.infer<typeof BulkActionCompletedEmailSchema>;
|
||||
|
||||
const previewDefaults: BulkActionCompletedEmailProps = {
|
||||
email: "bulk-action-completed",
|
||||
bulkActionId: "bulk_cmcxgmhjn0001cw7ct7g936uz",
|
||||
url: "http://localhost:3000/bulk-actions/123",
|
||||
totalCount: 100,
|
||||
successCount: 90,
|
||||
failureCount: 10,
|
||||
type: "CANCEL",
|
||||
createdAt: "10 Jul 2025, 15:04:04",
|
||||
completedAt: "10 Jul 2025, 15:05:04",
|
||||
};
|
||||
|
||||
export default function Email(props: BulkActionCompletedEmailProps) {
|
||||
const {
|
||||
bulkActionId,
|
||||
url,
|
||||
totalCount,
|
||||
successCount,
|
||||
failureCount,
|
||||
type,
|
||||
createdAt,
|
||||
completedAt,
|
||||
} = {
|
||||
...previewDefaults,
|
||||
...props,
|
||||
};
|
||||
|
||||
return (
|
||||
<Html>
|
||||
<Head />
|
||||
<Preview>Bulk action {bulkActionId} finished.</Preview>
|
||||
<Body style={main}>
|
||||
<Container style={container}>
|
||||
<Text style={h1}>Bulk action finished</Text>
|
||||
<Text style={paragraphLight}>Here's a summary of your bulk action:</Text>
|
||||
|
||||
<Property label="ID" value={bulkActionId} />
|
||||
<Property label="Started" value={`${createdAt} (UTC)`} />
|
||||
<Property label="Completed" value={`${completedAt} (UTC)`} />
|
||||
<Property label={`Successfully ${pastTense(type)}`} value={`${successCount} runs`} />
|
||||
<Property
|
||||
label={`Failed to ${type.toLocaleLowerCase()}`}
|
||||
value={`${failureCount} runs`}
|
||||
/>
|
||||
<Property label="Total runs" value={`${totalCount} runs`} />
|
||||
|
||||
<Button
|
||||
style={{
|
||||
...sans,
|
||||
boxSizing: "border-box",
|
||||
padding: "12px 24px",
|
||||
borderRadius: "8px",
|
||||
backgroundColor: "#615FFF",
|
||||
textAlign: "center",
|
||||
fontWeight: "600",
|
||||
color: "#ffffff",
|
||||
marginTop: "24px",
|
||||
marginBottom: "32px",
|
||||
}}
|
||||
href={url}
|
||||
>
|
||||
View bulk action
|
||||
</Button>
|
||||
<Image path="/emails/logo-mono.png" width="120" height="22" alt="Trigger.dev" />
|
||||
<Footer />
|
||||
</Container>
|
||||
</Body>
|
||||
</Html>
|
||||
);
|
||||
}
|
||||
|
||||
function Property({ label, value }: { label: string; value: string | number }) {
|
||||
return (
|
||||
<Row>
|
||||
<Column align="left" style={{ ...bullets, width: "33%" }}>
|
||||
{label}
|
||||
</Column>
|
||||
<Column align="left" style={{ ...bullets, ...grey, width: "66%" }}>
|
||||
{value}
|
||||
</Column>
|
||||
</Row>
|
||||
);
|
||||
}
|
||||
|
||||
function pastTense(type: "CANCEL" | "REPLAY") {
|
||||
switch (type) {
|
||||
case "CANCEL":
|
||||
return "canceled";
|
||||
case "REPLAY":
|
||||
return "replayed";
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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}>
|
||||
©Trigger.dev, 1111B S Governors Ave STE 6433, Dover, DE 19904 |{" "}
|
||||
<Link style={footerAnchor} href="https://trigger.dev/">
|
||||
Trigger.dev
|
||||
</Link>
|
||||
</Text>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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} />;
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
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 sans = {
|
||||
fontFamily:
|
||||
'-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Ubuntu,sans-serif',
|
||||
};
|
||||
|
||||
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 grey = {
|
||||
color: "#878C99",
|
||||
};
|
||||
|
||||
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",
|
||||
};
|
||||
@@ -0,0 +1,164 @@
|
||||
import {
|
||||
Body,
|
||||
CodeBlock,
|
||||
Column,
|
||||
Container,
|
||||
Head,
|
||||
Html,
|
||||
Link,
|
||||
Preview,
|
||||
Row,
|
||||
Text,
|
||||
dracula,
|
||||
} from "@react-email/components";
|
||||
import { z } from "zod";
|
||||
import { Footer } from "./components/Footer";
|
||||
import { Image } from "./components/Image";
|
||||
import { anchor, bullets, container, grey, h1, main, paragraphLight } from "./components/styles";
|
||||
|
||||
export const AlertDeploymentFailureEmailSchema = z.object({
|
||||
email: z.literal("alert-deployment-failure"),
|
||||
version: z.string(),
|
||||
environment: z.string(),
|
||||
organization: z.string(),
|
||||
shortCode: z.string(),
|
||||
failedAt: z.date(),
|
||||
error: z.object({
|
||||
name: z.string(),
|
||||
message: z.string(),
|
||||
stack: z.string().optional(),
|
||||
}),
|
||||
deploymentLink: z.string().url(),
|
||||
git: z
|
||||
.object({
|
||||
branchName: z.string(),
|
||||
shortSha: z.string(),
|
||||
commitMessage: z.string(),
|
||||
commitUrl: z.string(),
|
||||
branchUrl: z.string(),
|
||||
pullRequestNumber: z.number().optional(),
|
||||
pullRequestTitle: z.string().optional(),
|
||||
pullRequestUrl: z.string().optional(),
|
||||
})
|
||||
.optional(),
|
||||
vercelDeploymentUrl: z.string().url().optional(),
|
||||
});
|
||||
|
||||
const previewDefaults = {
|
||||
version: "v1",
|
||||
environment: "production",
|
||||
organization: "My Organization",
|
||||
shortCode: "abc123",
|
||||
failedAt: new Date().toISOString(),
|
||||
error: {
|
||||
name: "Error",
|
||||
stack: "Error: Something went wrong\n at main.ts:12:34",
|
||||
},
|
||||
deploymentLink: "https://trigger.dev",
|
||||
git: {
|
||||
branchName: "feat/new-feature",
|
||||
shortSha: "abc1234",
|
||||
commitMessage: "Add new background task for processing uploads",
|
||||
commitUrl: "https://github.com/acme/app/commit/abc1234",
|
||||
branchUrl: "https://github.com/acme/app/tree/feat/new-feature",
|
||||
pullRequestNumber: 42,
|
||||
pullRequestTitle: "Add upload processing",
|
||||
pullRequestUrl: "https://github.com/acme/app/pull/42",
|
||||
},
|
||||
vercelDeploymentUrl: "https://vercel.com/acme/app/abc1234",
|
||||
};
|
||||
|
||||
export default function Email(props: z.infer<typeof AlertDeploymentFailureEmailSchema>) {
|
||||
const {
|
||||
version,
|
||||
environment,
|
||||
organization,
|
||||
shortCode: _shortCode,
|
||||
failedAt: _failedAt,
|
||||
error,
|
||||
deploymentLink,
|
||||
git,
|
||||
vercelDeploymentUrl,
|
||||
} = {
|
||||
...previewDefaults,
|
||||
...props,
|
||||
};
|
||||
|
||||
return (
|
||||
<Html>
|
||||
<Head />
|
||||
<Preview>{`[${organization}] Deployment ${version} [${environment}] failed: ${error.name}`}</Preview>
|
||||
<Body style={main}>
|
||||
<Container style={container}>
|
||||
<Text
|
||||
style={h1}
|
||||
>{`An error occurred deploying ${version} in ${environment} in your ${organization} organization`}</Text>
|
||||
<Text style={paragraphLight}>
|
||||
{error.name} {error.message}
|
||||
</Text>
|
||||
{error.stack && (
|
||||
<CodeBlock code={error.stack} theme={dracula} lineNumbers language="log" />
|
||||
)}
|
||||
|
||||
<Link
|
||||
href={deploymentLink}
|
||||
target="_blank"
|
||||
style={{
|
||||
...anchor,
|
||||
display: "block",
|
||||
marginBottom: "50px",
|
||||
}}
|
||||
>
|
||||
Investigate this error
|
||||
</Link>
|
||||
|
||||
{git && (
|
||||
<>
|
||||
<Row>
|
||||
<Column style={{ ...bullets, width: "33%" }}>Branch</Column>
|
||||
<Column style={{ ...bullets, ...grey, width: "66%" }}>
|
||||
<Link href={git.branchUrl} style={anchor}>
|
||||
{git.branchName}
|
||||
</Link>
|
||||
</Column>
|
||||
</Row>
|
||||
<Row>
|
||||
<Column style={{ ...bullets, width: "33%" }}>Commit</Column>
|
||||
<Column style={{ ...bullets, ...grey, width: "66%" }}>
|
||||
<Link href={git.commitUrl} style={anchor}>
|
||||
{git.shortSha}
|
||||
</Link>{" "}
|
||||
{git.commitMessage}
|
||||
</Column>
|
||||
</Row>
|
||||
{git.pullRequestNumber && git.pullRequestUrl && (
|
||||
<Row>
|
||||
<Column style={{ ...bullets, width: "33%" }}>Pull Request</Column>
|
||||
<Column style={{ ...bullets, ...grey, width: "66%" }}>
|
||||
<Link href={git.pullRequestUrl} style={anchor}>
|
||||
#{git.pullRequestNumber}
|
||||
</Link>
|
||||
{git.pullRequestTitle ? ` ${git.pullRequestTitle}` : ""}
|
||||
</Column>
|
||||
</Row>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{vercelDeploymentUrl && (
|
||||
<Row>
|
||||
<Column style={{ ...bullets, width: "33%" }}>Vercel</Column>
|
||||
<Column style={{ ...bullets, ...grey, width: "66%" }}>
|
||||
<Link href={vercelDeploymentUrl} style={anchor}>
|
||||
View Vercel Deployment
|
||||
</Link>
|
||||
</Column>
|
||||
</Row>
|
||||
)}
|
||||
|
||||
<Image path="/emails/logo-mono.png" width="120" height="22" alt="Trigger.dev" />
|
||||
<Footer />
|
||||
</Container>
|
||||
</Body>
|
||||
</Html>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
import {
|
||||
Body,
|
||||
Column,
|
||||
Container,
|
||||
Head,
|
||||
Html,
|
||||
Link,
|
||||
Preview,
|
||||
Row,
|
||||
Text,
|
||||
} from "@react-email/components";
|
||||
import { z } from "zod";
|
||||
import { Footer } from "./components/Footer";
|
||||
import { Image } from "./components/Image";
|
||||
import { anchor, bullets, container, grey, h1, main } from "./components/styles";
|
||||
|
||||
export const AlertDeploymentSuccessEmailSchema = z.object({
|
||||
email: z.literal("alert-deployment-success"),
|
||||
version: z.string(),
|
||||
environment: z.string(),
|
||||
organization: z.string(),
|
||||
shortCode: z.string(),
|
||||
deployedAt: z.date(),
|
||||
taskCount: z.number(),
|
||||
deploymentLink: z.string().url(),
|
||||
git: z
|
||||
.object({
|
||||
branchName: z.string(),
|
||||
shortSha: z.string(),
|
||||
commitMessage: z.string(),
|
||||
commitUrl: z.string(),
|
||||
branchUrl: z.string(),
|
||||
pullRequestNumber: z.number().optional(),
|
||||
pullRequestTitle: z.string().optional(),
|
||||
pullRequestUrl: z.string().optional(),
|
||||
})
|
||||
.optional(),
|
||||
vercelDeploymentUrl: z.string().url().optional(),
|
||||
});
|
||||
|
||||
const previewDefaults = {
|
||||
version: "v1",
|
||||
environment: "production",
|
||||
organization: "My Organization",
|
||||
shortCode: "abc123",
|
||||
deployedAt: new Date().toISOString(),
|
||||
taskCount: 3,
|
||||
deploymentLink: "https://trigger.dev",
|
||||
git: {
|
||||
branchName: "feat/new-feature",
|
||||
shortSha: "abc1234",
|
||||
commitMessage: "Add new background task for processing uploads",
|
||||
commitUrl: "https://github.com/acme/app/commit/abc1234",
|
||||
branchUrl: "https://github.com/acme/app/tree/feat/new-feature",
|
||||
pullRequestNumber: 42,
|
||||
pullRequestTitle: "Add upload processing",
|
||||
pullRequestUrl: "https://github.com/acme/app/pull/42",
|
||||
},
|
||||
vercelDeploymentUrl: "https://vercel.com/acme/app/abc1234",
|
||||
};
|
||||
|
||||
export default function Email(props: z.infer<typeof AlertDeploymentSuccessEmailSchema>) {
|
||||
const {
|
||||
version,
|
||||
environment,
|
||||
organization,
|
||||
shortCode: _shortCode,
|
||||
deployedAt: _deployedAt,
|
||||
taskCount,
|
||||
deploymentLink,
|
||||
git,
|
||||
vercelDeploymentUrl,
|
||||
} = {
|
||||
...previewDefaults,
|
||||
...props,
|
||||
};
|
||||
|
||||
return (
|
||||
<Html>
|
||||
<Head />
|
||||
<Preview>{`[${organization}] Deployment ${version} [${environment}] succeeded`}</Preview>
|
||||
<Body style={main}>
|
||||
<Container style={container}>
|
||||
<Text
|
||||
style={h1}
|
||||
>{`Version ${version} successfully deployed ${taskCount} tasks in ${environment} in your ${organization} organization`}</Text>
|
||||
|
||||
<Link
|
||||
href={deploymentLink}
|
||||
target="_blank"
|
||||
style={{
|
||||
...anchor,
|
||||
display: "block",
|
||||
marginBottom: "50px",
|
||||
}}
|
||||
>
|
||||
View Deployment
|
||||
</Link>
|
||||
|
||||
{git && (
|
||||
<>
|
||||
<Row>
|
||||
<Column style={{ ...bullets, width: "33%" }}>Branch</Column>
|
||||
<Column style={{ ...bullets, ...grey, width: "66%" }}>
|
||||
<Link href={git.branchUrl} style={anchor}>
|
||||
{git.branchName}
|
||||
</Link>
|
||||
</Column>
|
||||
</Row>
|
||||
<Row>
|
||||
<Column style={{ ...bullets, width: "33%" }}>Commit</Column>
|
||||
<Column style={{ ...bullets, ...grey, width: "66%" }}>
|
||||
<Link href={git.commitUrl} style={anchor}>
|
||||
{git.shortSha}
|
||||
</Link>{" "}
|
||||
{git.commitMessage}
|
||||
</Column>
|
||||
</Row>
|
||||
{git.pullRequestNumber && git.pullRequestUrl && (
|
||||
<Row>
|
||||
<Column style={{ ...bullets, width: "33%" }}>Pull Request</Column>
|
||||
<Column style={{ ...bullets, ...grey, width: "66%" }}>
|
||||
<Link href={git.pullRequestUrl} style={anchor}>
|
||||
#{git.pullRequestNumber}
|
||||
</Link>
|
||||
{git.pullRequestTitle ? ` ${git.pullRequestTitle}` : ""}
|
||||
</Column>
|
||||
</Row>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{vercelDeploymentUrl && (
|
||||
<Row>
|
||||
<Column style={{ ...bullets, width: "33%" }}>Vercel</Column>
|
||||
<Column style={{ ...bullets, ...grey, width: "66%" }}>
|
||||
<Link href={vercelDeploymentUrl} style={anchor}>
|
||||
View Vercel Deployment
|
||||
</Link>
|
||||
</Column>
|
||||
</Row>
|
||||
)}
|
||||
|
||||
<Image path="/emails/logo-mono.png" width="120" height="22" alt="Trigger.dev" />
|
||||
<Footer />
|
||||
</Container>
|
||||
</Body>
|
||||
</Html>
|
||||
);
|
||||
}
|
||||
@@ -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 Trigger.dev.
|
||||
</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="Trigger.dev" />
|
||||
<Footer />
|
||||
</Container>
|
||||
</Body>
|
||||
</Html>
|
||||
);
|
||||
}
|
||||
@@ -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 Trigger.dev</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't try to log in, you can safely ignore this email.
|
||||
</Text>
|
||||
<Image path="/emails/logo-mono.png" width="120" height="22" alt="Trigger.dev" />
|
||||
<Footer />
|
||||
</Container>
|
||||
</Body>
|
||||
</Html>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { Body, Container, Head, Html, Preview, Text } from "@react-email/components";
|
||||
import { Footer } from "./components/Footer";
|
||||
import { Image } from "./components/Image";
|
||||
import { container, h1, main, paragraphLight } from "./components/styles";
|
||||
import { z } from "zod";
|
||||
|
||||
export const MfaDisabledEmailSchema = z.object({
|
||||
email: z.literal("mfa-disabled"),
|
||||
userEmail: z.string(),
|
||||
});
|
||||
|
||||
type MfaDisabledEmailProps = z.infer<typeof MfaDisabledEmailSchema>;
|
||||
|
||||
const previewDefaults: MfaDisabledEmailProps = {
|
||||
email: "mfa-disabled",
|
||||
userEmail: "user@example.com",
|
||||
};
|
||||
|
||||
export default function Email(props: MfaDisabledEmailProps) {
|
||||
const { userEmail } = {
|
||||
...previewDefaults,
|
||||
...props,
|
||||
};
|
||||
|
||||
return (
|
||||
<Html>
|
||||
<Head />
|
||||
<Preview>Multi-factor authentication disabled</Preview>
|
||||
<Body style={main}>
|
||||
<Container style={container}>
|
||||
<Text style={h1}>Multi-factor authentication disabled</Text>
|
||||
<Text style={paragraphLight}>Hi there,</Text>
|
||||
<Text style={paragraphLight}>
|
||||
You have successfully disabled multi-factor authentication (MFA) for your Trigger.dev
|
||||
account ({userEmail}). Your account no longer has the additional security layer provided
|
||||
by MFA.
|
||||
</Text>
|
||||
<Text style={paragraphLight}>
|
||||
You can re-enable MFA at any time from your account security page. If you didn't disable
|
||||
MFA, please contact our support team immediately.
|
||||
</Text>
|
||||
<Image path="/emails/logo-mono.png" width="120" height="22" alt="Trigger.dev" />
|
||||
<Footer />
|
||||
</Container>
|
||||
</Body>
|
||||
</Html>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { Body, Container, Head, Html, Preview, Text } from "@react-email/components";
|
||||
import { Footer } from "./components/Footer";
|
||||
import { Image } from "./components/Image";
|
||||
import { container, h1, main, paragraphLight } from "./components/styles";
|
||||
import { z } from "zod";
|
||||
|
||||
export const MfaEnabledEmailSchema = z.object({
|
||||
email: z.literal("mfa-enabled"),
|
||||
userEmail: z.string(),
|
||||
});
|
||||
|
||||
type MfaEnabledEmailProps = z.infer<typeof MfaEnabledEmailSchema>;
|
||||
|
||||
const previewDefaults: MfaEnabledEmailProps = {
|
||||
email: "mfa-enabled",
|
||||
userEmail: "user@example.com",
|
||||
};
|
||||
|
||||
export default function Email(props: MfaEnabledEmailProps) {
|
||||
const { userEmail } = {
|
||||
...previewDefaults,
|
||||
...props,
|
||||
};
|
||||
|
||||
return (
|
||||
<Html>
|
||||
<Head />
|
||||
<Preview>Multi-factor authentication enabled ✅</Preview>
|
||||
<Body style={main}>
|
||||
<Container style={container}>
|
||||
<Text style={h1}>Multi-factor authentication enabled</Text>
|
||||
<Text style={paragraphLight}>Hi there,</Text>
|
||||
<Text style={paragraphLight}>
|
||||
Multi-factor authentication was successfully enabled for your Trigger.dev account (
|
||||
{userEmail}). If you did not make this change, contact our support team immediately.
|
||||
</Text>
|
||||
<Text style={paragraphLight}>
|
||||
<strong>Staying secure:</strong>
|
||||
</Text>
|
||||
<Text style={paragraphLight}>
|
||||
• Keep your authenticator app safe and secured
|
||||
<br />• Never share your MFA codes with anyone
|
||||
<br />• Store your recovery codes in a secure location
|
||||
</Text>
|
||||
<Text
|
||||
style={{
|
||||
...paragraphLight,
|
||||
display: "block",
|
||||
marginBottom: "50px",
|
||||
}}
|
||||
>
|
||||
Your account now has an additional layer of protection and you'll need to enter a code
|
||||
from your authenticator app when logging in.
|
||||
</Text>
|
||||
<Image path="/emails/logo-mono.png" width="120" height="22" alt="Trigger.dev" />
|
||||
<Footer />
|
||||
</Container>
|
||||
</Body>
|
||||
</Html>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { Body, Head, Html, Link, Preview, 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>Power up your workflows</Preview>
|
||||
<Body style={main}>
|
||||
<Text style={paragraphLight}>Hey {name ?? "there"},</Text>
|
||||
<Text style={paragraphLight}>I’m Matt, CEO of Trigger.dev.</Text>
|
||||
<Text style={paragraphLight}>
|
||||
Our goal is to give developers like you the ability to effortlessly create powerful
|
||||
workflows in code.
|
||||
</Text>
|
||||
<Text style={paragraphLight}>
|
||||
We recommend{" "}
|
||||
<Link style={anchor} href="https://app.trigger.dev/templates">
|
||||
getting started with one of our templates
|
||||
</Link>{" "}
|
||||
to get familiar with how Trigger.dev works, and then moving on to create your own
|
||||
workflows.
|
||||
</Text>
|
||||
|
||||
<Text style={paragraphLight}>
|
||||
Feel free to reply to me if you have any questions. You can also{" "}
|
||||
<Link style={anchor} href="https://cal.com/team/triggerdotdev/call">
|
||||
schedule a call
|
||||
</Link>{" "}
|
||||
, or join our{" "}
|
||||
<Link style={anchor} href="https://discord.gg/JtBAxBr2m3">
|
||||
Discord server
|
||||
</Link>{" "}
|
||||
to connect with the community and our team.
|
||||
</Text>
|
||||
|
||||
<Text style={paragraphLight}>We hope you enjoy using Trigger.dev!</Text>
|
||||
|
||||
<Text style={bullets}>Best,</Text>
|
||||
<Text style={bullets}>Matt</Text>
|
||||
<Text style={paragraphLight}>CEO, Trigger.dev</Text>
|
||||
<Text style={footerItalic}>
|
||||
If you don’t want me to contact you again, please just let me know and I’ll update your
|
||||
preferences.
|
||||
</Text>
|
||||
<Footer />
|
||||
</Body>
|
||||
</Html>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"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",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-sesv2": "^3.716.0",
|
||||
"@react-email/components": "1.0.12",
|
||||
"@react-email/render": "^2.0.8",
|
||||
"nodemailer": "^8.0.6",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"resend": "^3.2.0",
|
||||
"tiny-invariant": "^1.2.0",
|
||||
"zod": "3.25.76"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/nodemailer": "^8.0.0",
|
||||
"@types/react": "18.2.69",
|
||||
"@types/react-dom": "18.2.7",
|
||||
"react-email": "^6.5.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
import type { ReactElement } from "react";
|
||||
|
||||
import { z } from "zod";
|
||||
import AlertAttemptFailureEmail, { AlertAttemptEmailSchema } from "../emails/alert-attempt-failure";
|
||||
import AlertErrorGroupEmail, { AlertErrorGroupEmailSchema } from "../emails/alert-error-group";
|
||||
import AlertRunFailureEmail, { AlertRunEmailSchema } from "../emails/alert-run-failure";
|
||||
import { setGlobalBasePath } from "../emails/components/BasePath";
|
||||
import AlertDeploymentFailureEmail, {
|
||||
AlertDeploymentFailureEmailSchema,
|
||||
} from "../emails/deployment-failure";
|
||||
import AlertDeploymentSuccessEmail, {
|
||||
AlertDeploymentSuccessEmailSchema,
|
||||
} from "../emails/deployment-success";
|
||||
import InviteEmail, { InviteEmailSchema } from "../emails/invite";
|
||||
import MagicLinkEmail from "../emails/magic-link";
|
||||
|
||||
import type { MailTransport, MailTransportOptions } from "./transports";
|
||||
import { constructMailTransport } from "./transports";
|
||||
import MfaEnabledEmail, { MfaEnabledEmailSchema } from "../emails/mfa-enabled";
|
||||
import MfaDisabledEmail, { MfaDisabledEmailSchema } from "../emails/mfa-disabled";
|
||||
import BulkActionCompletedEmail, {
|
||||
BulkActionCompletedEmailSchema,
|
||||
} from "../emails/bulk-action-complete";
|
||||
|
||||
export { type MailTransportOptions };
|
||||
|
||||
export const DeliverEmailSchema = z
|
||||
.discriminatedUnion("email", [
|
||||
z.object({
|
||||
email: z.literal("magic_link"),
|
||||
magicLink: z.string().url(),
|
||||
}),
|
||||
InviteEmailSchema,
|
||||
AlertRunEmailSchema,
|
||||
AlertAttemptEmailSchema,
|
||||
AlertErrorGroupEmailSchema,
|
||||
AlertDeploymentFailureEmailSchema,
|
||||
AlertDeploymentSuccessEmailSchema,
|
||||
MfaEnabledEmailSchema,
|
||||
MfaDisabledEmailSchema,
|
||||
BulkActionCompletedEmailSchema,
|
||||
])
|
||||
.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 Trigger.dev",
|
||||
component: <MagicLinkEmail magicLink={data.magicLink} />,
|
||||
};
|
||||
case "invite":
|
||||
return {
|
||||
subject: `You've been invited to join ${data.orgName} on Trigger.dev`,
|
||||
component: <InviteEmail {...data} />,
|
||||
};
|
||||
case "alert-attempt": {
|
||||
return {
|
||||
subject: `[${data.organization}] Error on ${data.taskIdentifier} [${data.version}.${data.environment}] ${data.error.message}`,
|
||||
component: <AlertAttemptFailureEmail {...data} />,
|
||||
};
|
||||
}
|
||||
case "alert-run": {
|
||||
return {
|
||||
subject: `[${data.organization}] Run ${data.runId} failed for ${data.taskIdentifier} [${
|
||||
data.version
|
||||
}.${data.environment}] ${formatErrorMessageForSubject(data.error.message)}`,
|
||||
component: <AlertRunFailureEmail {...data} />,
|
||||
};
|
||||
}
|
||||
case "alert-error-group": {
|
||||
const classLabel =
|
||||
data.classification === "new_issue"
|
||||
? "New error"
|
||||
: data.classification === "regression"
|
||||
? "Regression"
|
||||
: "Error resurfaced";
|
||||
return {
|
||||
subject: `[${data.organization}] ${classLabel}: ${data.error.type ?? "Error"} in ${data.taskIdentifier} [${data.environment}]`,
|
||||
component: <AlertErrorGroupEmail {...data} />,
|
||||
};
|
||||
}
|
||||
case "alert-deployment-failure": {
|
||||
return {
|
||||
subject: `[${data.organization}] Deployment ${data.version} [${data.environment}] failed: ${data.error.name}`,
|
||||
component: <AlertDeploymentFailureEmail {...data} />,
|
||||
};
|
||||
}
|
||||
case "alert-deployment-success": {
|
||||
return {
|
||||
subject: `[${data.organization}] Deployment ${data.version} [${data.environment}] succeeded`,
|
||||
component: <AlertDeploymentSuccessEmail {...data} />,
|
||||
};
|
||||
}
|
||||
case "mfa-enabled": {
|
||||
return {
|
||||
subject: `Multi-factor authentication enabled on your Trigger.dev account`,
|
||||
component: <MfaEnabledEmail {...data} />,
|
||||
};
|
||||
}
|
||||
case "mfa-disabled": {
|
||||
return {
|
||||
subject: `Multi-factor authentication disabled on your Trigger.dev account`,
|
||||
component: <MfaDisabledEmail {...data} />,
|
||||
};
|
||||
}
|
||||
case "bulk-action-completed": {
|
||||
return {
|
||||
subject: `Bulk action finished`,
|
||||
component: <BulkActionCompletedEmail {...data} />,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function formatErrorMessageForSubject(message?: string) {
|
||||
if (!message) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const singleLine = message.replace(/[\r\n]+/g, " ");
|
||||
return singleLine.length > 30 ? singleLine.substring(0, 27) + "..." : singleLine;
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { render } from "@react-email/render";
|
||||
import type { MailMessage, MailTransport, PlainTextMailMessage } from "./index";
|
||||
import { EmailError } from "./index";
|
||||
import nodemailer from "nodemailer";
|
||||
import { SESv2Client, SendEmailCommand } from "@aws-sdk/client-sesv2";
|
||||
|
||||
export type AwsSesMailTransportOptions = {
|
||||
type: "aws-ses";
|
||||
};
|
||||
|
||||
export class AwsSesMailTransport implements MailTransport {
|
||||
#client: nodemailer.Transporter;
|
||||
|
||||
constructor(options: AwsSesMailTransportOptions) {
|
||||
const sesClient = new SESv2Client();
|
||||
|
||||
this.#client = nodemailer.createTransport({
|
||||
SES: { sesClient, SendEmailCommand },
|
||||
});
|
||||
}
|
||||
|
||||
async send({ to, from, replyTo, subject, react }: MailMessage): Promise<void> {
|
||||
try {
|
||||
await this.#client.sendMail({
|
||||
from: from,
|
||||
to,
|
||||
replyTo: replyTo,
|
||||
subject,
|
||||
html: await 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import type { ReactElement } from "react";
|
||||
import type { AwsSesMailTransportOptions } from "./aws-ses";
|
||||
import { AwsSesMailTransport } from "./aws-ses";
|
||||
import type { NullMailTransportOptions } from "./null";
|
||||
import { NullMailTransport } from "./null";
|
||||
import type { ResendMailTransportOptions } from "./resend";
|
||||
import { ResendMailTransport } from "./resend";
|
||||
import type { SmtpMailTransportOptions } from "./smtp";
|
||||
import { SmtpMailTransport } 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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { render } from "@react-email/render";
|
||||
import type { 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> {
|
||||
const plainText = await render(react, {
|
||||
plainText: true,
|
||||
});
|
||||
|
||||
console.log(`
|
||||
##### sendEmail to ${to}, subject: ${subject}
|
||||
|
||||
${plainText}
|
||||
`);
|
||||
}
|
||||
|
||||
async sendPlainText({ to, subject, text }: PlainTextMailMessage): Promise<void> {
|
||||
console.log(`
|
||||
##### sendEmail to ${to}, subject: ${subject}
|
||||
|
||||
${text}
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import type { MailMessage, MailTransport, PlainTextMailMessage } from "./index";
|
||||
import { EmailError } 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { render } from "@react-email/render";
|
||||
import nodemailer from "nodemailer";
|
||||
import type { MailMessage, MailTransport, PlainTextMailMessage } from "./index";
|
||||
import { EmailError } 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: await 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
Reference in New Issue
Block a user