源码快照 @ 2026-09-02 (source-only, 剔除非源码素材)
This commit is contained in:
@@ -0,0 +1,22 @@
|
||||
import { FeedbackEmail, type FeedbackEmailProps } from "../src/templates/feedback"
|
||||
|
||||
export default function FeedbackPreview(props: FeedbackEmailProps) {
|
||||
return <FeedbackEmail {...props} />
|
||||
}
|
||||
|
||||
FeedbackPreview.PreviewProps = {
|
||||
name: "Jane Doe",
|
||||
email: "jane@example.com",
|
||||
message: "I tried to connect a worker from Settings, but the connection stayed pending after the token was accepted.",
|
||||
source: "openwork-app",
|
||||
entrypoint: "settings",
|
||||
deployment: "desktop",
|
||||
appVersion: "0.13.5",
|
||||
openworkServerVersion: "0.13.5",
|
||||
opencodeVersion: "1.4.9",
|
||||
osName: "macOS",
|
||||
osVersion: "15.4",
|
||||
platform: "MacIntel",
|
||||
diagnosticsSummary: "Source: openwork-app\nEntrypoint: settings\nDeployment: desktop\nApp version: 0.13.5\nOS: macOS 15.4\nPlatform: MacIntel",
|
||||
submittedAt: "2026-05-11T18:30:00.000Z",
|
||||
} satisfies FeedbackEmailProps
|
||||
@@ -0,0 +1,16 @@
|
||||
import {
|
||||
OrganizationInviteEmail,
|
||||
type OrganizationInviteEmailProps,
|
||||
} from "../src/templates/organization-invite"
|
||||
|
||||
export default function OrganizationInvitePreview(props: OrganizationInviteEmailProps) {
|
||||
return <OrganizationInviteEmail {...props} />
|
||||
}
|
||||
|
||||
OrganizationInvitePreview.PreviewProps = {
|
||||
inviteLink: "https://app.openworklabs.com/join-org?invite=invitation_preview",
|
||||
invitedByName: "Ada Lovelace",
|
||||
invitedByEmail: "ada@example.com",
|
||||
organizationName: "OpenWork Preview",
|
||||
role: "admin",
|
||||
} satisfies OrganizationInviteEmailProps
|
||||
@@ -0,0 +1,9 @@
|
||||
import { PasswordResetEmail, type PasswordResetEmailProps } from "../src/templates/password-reset"
|
||||
|
||||
export default function PasswordResetPreview(props: PasswordResetEmailProps) {
|
||||
return <PasswordResetEmail {...props} />
|
||||
}
|
||||
|
||||
PasswordResetPreview.PreviewProps = {
|
||||
resetLink: "https://app.openworklabs.com/api/auth/reset-password/example-token?callbackURL=https%3A%2F%2Fapp.openworklabs.com%2Freset-password",
|
||||
} satisfies PasswordResetEmailProps
|
||||
@@ -0,0 +1,9 @@
|
||||
import { VerificationEmail, type VerificationEmailProps } from "../src/templates/verification"
|
||||
|
||||
export default function VerificationPreview(props: VerificationEmailProps) {
|
||||
return <VerificationEmail {...props} />
|
||||
}
|
||||
|
||||
VerificationPreview.PreviewProps = {
|
||||
verificationCode: "123456",
|
||||
} satisfies VerificationEmailProps
|
||||
@@ -0,0 +1,42 @@
|
||||
{
|
||||
"name": "@openwork/email",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./src/index.ts",
|
||||
"development": "./src/index.ts",
|
||||
"default": "./dist/index.js"
|
||||
},
|
||||
"./templates": {
|
||||
"types": "./src/templates/index.ts",
|
||||
"development": "./src/templates/index.ts",
|
||||
"default": "./dist/templates/index.js"
|
||||
}
|
||||
},
|
||||
"files": [
|
||||
"dist",
|
||||
"src",
|
||||
"emails"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "tsup",
|
||||
"dev": "email dev --dir emails"
|
||||
},
|
||||
"dependencies": {
|
||||
"@react-email/components": "^1.0.12",
|
||||
"@react-email/render": "^2.1.0",
|
||||
"nodemailer": "^9.0.3",
|
||||
"react": "catalog:",
|
||||
"react-dom": "catalog:",
|
||||
"react-email": "6.9.1",
|
||||
"resend": "^6.12.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@react-email/ui": "6.9.1",
|
||||
"@types/nodemailer": "^8.0.0",
|
||||
"@types/react": "^19.2.14",
|
||||
"tsup": "^8.5.0",
|
||||
"typescript": "^5.6.3"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
export {
|
||||
EmailSendError,
|
||||
sendEmail,
|
||||
type EmailProvider,
|
||||
type EmailSendConfig,
|
||||
type SendEmailInput,
|
||||
type SmtpEmailConfig,
|
||||
} from "./send-email.js"
|
||||
export { renderEmailHtml } from "./render-email.js"
|
||||
export {
|
||||
emailSubjects,
|
||||
emailReplyTo,
|
||||
renderEmailTemplate,
|
||||
type EmailTemplate,
|
||||
type EmailTemplateProps,
|
||||
type DownloadLinkEmailProps,
|
||||
type FeedbackEmailProps,
|
||||
type OrganizationInviteEmailProps,
|
||||
type PasswordResetEmailProps,
|
||||
type VerificationEmailProps,
|
||||
} from "./templates/index.js"
|
||||
@@ -0,0 +1,9 @@
|
||||
import { render } from "@react-email/render"
|
||||
import { renderEmailTemplate, type EmailTemplate, type EmailTemplateProps } from "./templates/index.js"
|
||||
|
||||
export function renderEmailHtml<Template extends EmailTemplate>(
|
||||
template: Template,
|
||||
props: EmailTemplateProps[Template],
|
||||
) {
|
||||
return render(renderEmailTemplate(template, props))
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
import { render } from "@react-email/render"
|
||||
import nodemailer from "nodemailer"
|
||||
import { Resend } from "resend"
|
||||
import { emailReplyTo, emailSubjects, type EmailTemplate, type EmailTemplateProps, renderEmailTemplate } from "./templates/index.js"
|
||||
|
||||
export type EmailProvider = "dev" | "resend" | "nodemailer"
|
||||
|
||||
export type SmtpEmailConfig = {
|
||||
host?: string
|
||||
port?: number
|
||||
user?: string
|
||||
pass?: string
|
||||
secure?: boolean
|
||||
}
|
||||
|
||||
export type EmailSendConfig = {
|
||||
from?: string
|
||||
resendApiKey?: string
|
||||
smtp?: SmtpEmailConfig
|
||||
devMode?: boolean
|
||||
}
|
||||
|
||||
export class EmailSendError extends Error {
|
||||
readonly reason: "email_not_configured" | "resend_rejected" | "resend_network" | "nodemailer_rejected"
|
||||
readonly template: EmailTemplate
|
||||
readonly recipient: string
|
||||
readonly detail?: string
|
||||
|
||||
constructor(input: {
|
||||
template: EmailTemplate
|
||||
reason: EmailSendError["reason"]
|
||||
recipient: string
|
||||
detail?: string
|
||||
}) {
|
||||
super(`[${input.template}] email for ${input.recipient} failed: ${input.reason}${input.detail ? ` (${input.detail})` : ""}`)
|
||||
this.name = "EmailSendError"
|
||||
this.reason = input.reason
|
||||
this.template = input.template
|
||||
this.recipient = input.recipient
|
||||
this.detail = input.detail
|
||||
}
|
||||
}
|
||||
|
||||
function emailNotConfigured(input: {
|
||||
template: EmailTemplate
|
||||
recipient: string
|
||||
detail: string
|
||||
}) {
|
||||
return new EmailSendError({
|
||||
template: input.template,
|
||||
reason: "email_not_configured",
|
||||
recipient: input.recipient,
|
||||
detail: input.detail,
|
||||
})
|
||||
}
|
||||
|
||||
export type SendEmailInput<Template extends EmailTemplate = EmailTemplate> = {
|
||||
to: string
|
||||
template: Template
|
||||
props: EmailTemplateProps[Template]
|
||||
config: EmailSendConfig
|
||||
subject?: string
|
||||
}
|
||||
|
||||
export async function sendEmail<Template extends EmailTemplate>(input: SendEmailInput<Template>) {
|
||||
const to = input.to.trim()
|
||||
if (!to) {
|
||||
return
|
||||
}
|
||||
|
||||
const subject = input.subject ?? emailSubjects[input.template](input.props)
|
||||
const replyTo = emailReplyTo[input.template](input.props)?.trim() || undefined
|
||||
const provider = getEmailProvider(input.config)
|
||||
|
||||
if (provider === "dev") {
|
||||
console.info(`[email] dev email payload for ${to}: ${JSON.stringify({ template: input.template, subject, replyTo, props: input.props })}`)
|
||||
return
|
||||
}
|
||||
|
||||
const component = renderEmailTemplate(input.template, input.props)
|
||||
const [html, text] = await Promise.all([
|
||||
render(component),
|
||||
render(component, { plainText: true }),
|
||||
])
|
||||
|
||||
if (provider === "resend") {
|
||||
await sendViaResend({ to, subject, replyTo, html, text, template: input.template, config: input.config })
|
||||
return
|
||||
}
|
||||
|
||||
await sendViaNodemailer({ to, subject, replyTo, html, text, template: input.template, config: input.config })
|
||||
}
|
||||
|
||||
function getEmailProvider(config: EmailSendConfig): EmailProvider {
|
||||
if (config.smtp?.host?.trim()) {
|
||||
return "nodemailer"
|
||||
}
|
||||
if (config.resendApiKey?.trim()) {
|
||||
return "resend"
|
||||
}
|
||||
if (config.devMode) {
|
||||
return "dev"
|
||||
}
|
||||
return "nodemailer"
|
||||
}
|
||||
|
||||
async function sendViaResend(input: {
|
||||
to: string
|
||||
subject: string
|
||||
replyTo?: string
|
||||
html: string
|
||||
text: string
|
||||
template: EmailTemplate
|
||||
config: EmailSendConfig
|
||||
}) {
|
||||
const from = input.config.from
|
||||
const apiKey = input.config.resendApiKey
|
||||
if (!apiKey || !from) {
|
||||
throw emailNotConfigured({
|
||||
template: input.template,
|
||||
recipient: input.to,
|
||||
detail: "Resend transactional email requires EMAIL_FROM and RESEND_API_KEY",
|
||||
})
|
||||
}
|
||||
|
||||
try {
|
||||
const resend = new Resend(apiKey)
|
||||
const result = await resend.emails.send({
|
||||
from,
|
||||
to: input.to,
|
||||
subject: input.subject,
|
||||
replyTo: input.replyTo,
|
||||
html: input.html,
|
||||
text: input.text,
|
||||
})
|
||||
|
||||
if (result.error) {
|
||||
throw new EmailSendError({
|
||||
template: input.template,
|
||||
reason: "resend_rejected",
|
||||
recipient: input.to,
|
||||
detail: result.error.message,
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof EmailSendError) {
|
||||
throw error
|
||||
}
|
||||
const message = error instanceof Error ? error.message : "Unknown error"
|
||||
throw new EmailSendError({ template: input.template, reason: "resend_network", recipient: input.to, detail: message })
|
||||
}
|
||||
}
|
||||
|
||||
async function sendViaNodemailer(input: {
|
||||
to: string
|
||||
subject: string
|
||||
replyTo?: string
|
||||
html: string
|
||||
text: string
|
||||
template: EmailTemplate
|
||||
config: EmailSendConfig
|
||||
}) {
|
||||
const from = input.config.from
|
||||
const smtp = input.config.smtp
|
||||
if (!from || !smtp?.host) {
|
||||
throw emailNotConfigured({
|
||||
template: input.template,
|
||||
recipient: input.to,
|
||||
detail: "SMTP transactional email requires EMAIL_FROM and SMTP_HOST, or configure RESEND_API_KEY",
|
||||
})
|
||||
}
|
||||
|
||||
try {
|
||||
const transporter = nodemailer.createTransport({
|
||||
host: smtp.host,
|
||||
port: smtp.port ?? 587,
|
||||
secure: smtp.secure ?? false,
|
||||
auth: smtp.user
|
||||
? {
|
||||
user: smtp.user,
|
||||
pass: smtp.pass,
|
||||
}
|
||||
: undefined,
|
||||
})
|
||||
|
||||
await transporter.sendMail({
|
||||
from,
|
||||
to: input.to,
|
||||
subject: input.subject,
|
||||
replyTo: input.replyTo,
|
||||
html: input.html,
|
||||
text: input.text,
|
||||
})
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Unknown error"
|
||||
throw new EmailSendError({ template: input.template, reason: "nodemailer_rejected", recipient: input.to, detail: message })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
export const DEFAULT_OPENWORK_DOWNLOAD_URL = "https://openworklabs.com/download"
|
||||
|
||||
export type DesktopCapability = {
|
||||
title: string
|
||||
description: string
|
||||
}
|
||||
|
||||
export const desktopCapabilities: DesktopCapability[] = [
|
||||
{
|
||||
title: "Edit spreadsheets",
|
||||
description: "Create, clean, and transform CSV and Excel files.",
|
||||
},
|
||||
{
|
||||
title: "Control your browser",
|
||||
description: "Automate the built-in browser for repetitive web tasks.",
|
||||
},
|
||||
{
|
||||
title: "Organize files",
|
||||
description: "Read, write, and manage files and folders.",
|
||||
},
|
||||
{
|
||||
title: "Automate tasks",
|
||||
description: "Build reusable workflows with skills and commands.",
|
||||
},
|
||||
{
|
||||
title: "Generate content",
|
||||
description: "Draft documents, emails, and reports.",
|
||||
},
|
||||
{
|
||||
title: "Connect to APIs",
|
||||
description: "Plug into external services and tools via MCP.",
|
||||
},
|
||||
]
|
||||
@@ -0,0 +1,106 @@
|
||||
import type { CSSProperties } from "react"
|
||||
import { Body, Button, Container, Head, Heading, Html, Preview, Section, Text } from "@react-email/components"
|
||||
import { desktopCapabilities } from "./desktop-capabilities.js"
|
||||
|
||||
export type DownloadLinkEmailProps = {
|
||||
organizationName?: string
|
||||
downloadUrl: string
|
||||
}
|
||||
|
||||
export function DownloadLinkEmail({ organizationName, downloadUrl }: DownloadLinkEmailProps) {
|
||||
const intro = organizationName
|
||||
? `Here's your link to download the OpenWork desktop app for ${organizationName}.`
|
||||
: "Here's your link to download the OpenWork desktop app."
|
||||
|
||||
return (
|
||||
<Html>
|
||||
<Head />
|
||||
<Preview>Your OpenWork download link</Preview>
|
||||
<Body style={styles.body}>
|
||||
<Container style={styles.container}>
|
||||
<Text style={styles.eyebrow}>OpenWork desktop</Text>
|
||||
<Heading style={styles.heading}>Download OpenWork</Heading>
|
||||
<Text style={styles.text}>{intro}</Text>
|
||||
|
||||
<Section style={styles.capabilitiesBox}>
|
||||
{desktopCapabilities.map((capability) => (
|
||||
<Text key={capability.title} style={styles.capability}>
|
||||
<strong>{capability.title}</strong> — {capability.description}
|
||||
</Text>
|
||||
))}
|
||||
</Section>
|
||||
|
||||
<Button href={downloadUrl} style={styles.button}>Download OpenWork</Button>
|
||||
<Text style={styles.footer}>Signing in inside the app syncs your team's shared skills.</Text>
|
||||
</Container>
|
||||
</Body>
|
||||
</Html>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = {
|
||||
body: {
|
||||
backgroundColor: "#f6f4ef",
|
||||
color: "#171412",
|
||||
fontFamily: "Arial, sans-serif",
|
||||
margin: 0,
|
||||
},
|
||||
container: {
|
||||
backgroundColor: "#fffdf8",
|
||||
border: "1px solid #e8dfd0",
|
||||
borderRadius: "20px",
|
||||
margin: "40px auto",
|
||||
maxWidth: "560px",
|
||||
padding: "32px",
|
||||
},
|
||||
eyebrow: {
|
||||
color: "#8a5a28",
|
||||
fontSize: "13px",
|
||||
fontWeight: 700,
|
||||
letterSpacing: "0.08em",
|
||||
margin: "0 0 12px",
|
||||
textTransform: "uppercase",
|
||||
},
|
||||
heading: {
|
||||
color: "#171412",
|
||||
fontSize: "28px",
|
||||
lineHeight: "34px",
|
||||
margin: "0 0 16px",
|
||||
},
|
||||
text: {
|
||||
color: "#4d4640",
|
||||
fontSize: "16px",
|
||||
lineHeight: "24px",
|
||||
margin: "0 0 24px",
|
||||
},
|
||||
capabilitiesBox: {
|
||||
backgroundColor: "#f8f1e6",
|
||||
border: "1px solid #eadcc8",
|
||||
borderRadius: "16px",
|
||||
margin: "0 0 24px",
|
||||
padding: "20px",
|
||||
},
|
||||
capability: {
|
||||
color: "#4d4640",
|
||||
fontSize: "14px",
|
||||
lineHeight: "21px",
|
||||
margin: "0 0 8px",
|
||||
},
|
||||
button: {
|
||||
backgroundColor: "#171412",
|
||||
borderRadius: "999px",
|
||||
color: "#fff8eb",
|
||||
display: "inline-block",
|
||||
fontSize: "15px",
|
||||
fontWeight: 700,
|
||||
marginBottom: "24px",
|
||||
padding: "13px 22px",
|
||||
textDecoration: "none",
|
||||
},
|
||||
footer: {
|
||||
color: "#756c62",
|
||||
fontSize: "14px",
|
||||
lineHeight: "21px",
|
||||
margin: 0,
|
||||
},
|
||||
} satisfies Record<string, CSSProperties>
|
||||
@@ -0,0 +1,160 @@
|
||||
import { Body, Container, Head, Heading, Hr, Html, Preview, Section, Text } from "@react-email/components"
|
||||
|
||||
export type FeedbackEmailProps = {
|
||||
name: string
|
||||
email: string
|
||||
message: string
|
||||
mode?: "feedback" | "contact"
|
||||
source: string
|
||||
entrypoint: string
|
||||
deployment: string
|
||||
appVersion: string
|
||||
openworkServerVersion: string
|
||||
opencodeVersion: string
|
||||
osName: string
|
||||
osVersion: string
|
||||
platform: string
|
||||
diagnosticsSummary: string
|
||||
submittedAt: string
|
||||
}
|
||||
|
||||
export function FeedbackEmail({
|
||||
name,
|
||||
email,
|
||||
message,
|
||||
mode = "feedback",
|
||||
source,
|
||||
entrypoint,
|
||||
deployment,
|
||||
appVersion,
|
||||
openworkServerVersion,
|
||||
opencodeVersion,
|
||||
osName,
|
||||
osVersion,
|
||||
platform,
|
||||
diagnosticsSummary,
|
||||
submittedAt,
|
||||
}: FeedbackEmailProps) {
|
||||
const osLabel = [osName, osVersion].filter(Boolean).join(" ")
|
||||
const isContact = mode === "contact"
|
||||
const label = isContact ? "contact message" : "feedback"
|
||||
const metadata = [
|
||||
["Source", source],
|
||||
["Entrypoint", entrypoint],
|
||||
["Deployment", deployment],
|
||||
["App version", appVersion],
|
||||
["OpenWork server", openworkServerVersion],
|
||||
["OpenCode", opencodeVersion],
|
||||
["OS", osLabel],
|
||||
["Platform", platform],
|
||||
["Submitted", submittedAt],
|
||||
].filter(([, value]) => value)
|
||||
|
||||
return (
|
||||
<Html>
|
||||
<Head />
|
||||
<Preview>{name} sent an OpenWork {label} from {entrypoint || source || "unknown"}</Preview>
|
||||
<Body style={styles.body}>
|
||||
<Container style={styles.container}>
|
||||
<Text style={styles.eyebrow}>{isContact ? "OpenWork contact" : "OpenWork feedback"}</Text>
|
||||
<Heading style={styles.heading}>{isContact ? "Contact message" : "Feedback"} from {name}</Heading>
|
||||
<Text style={styles.contact}>{email}</Text>
|
||||
|
||||
<Section style={styles.messageBox}>
|
||||
<Text style={styles.message}>{message}</Text>
|
||||
</Section>
|
||||
|
||||
<Hr style={styles.hr} />
|
||||
<Text style={styles.sectionTitle}>Context</Text>
|
||||
{metadata.map(([label, value]) => (
|
||||
<Text key={label} style={styles.metaLine}>
|
||||
<strong>{label}:</strong> {value}
|
||||
</Text>
|
||||
))}
|
||||
|
||||
{diagnosticsSummary ? (
|
||||
<>
|
||||
<Hr style={styles.hr} />
|
||||
<Text style={styles.sectionTitle}>Diagnostics summary</Text>
|
||||
<Text style={styles.diagnostics}>{diagnosticsSummary}</Text>
|
||||
</>
|
||||
) : null}
|
||||
</Container>
|
||||
</Body>
|
||||
</Html>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = {
|
||||
body: {
|
||||
backgroundColor: "#f6f4ef",
|
||||
color: "#171412",
|
||||
fontFamily: "Arial, sans-serif",
|
||||
margin: 0,
|
||||
},
|
||||
container: {
|
||||
backgroundColor: "#fffdf8",
|
||||
border: "1px solid #e8dfd0",
|
||||
borderRadius: "20px",
|
||||
margin: "40px auto",
|
||||
maxWidth: "620px",
|
||||
padding: "32px",
|
||||
},
|
||||
eyebrow: {
|
||||
color: "#8a5a28",
|
||||
fontSize: "13px",
|
||||
fontWeight: 700,
|
||||
letterSpacing: "0.08em",
|
||||
margin: "0 0 12px",
|
||||
textTransform: "uppercase" as const,
|
||||
},
|
||||
heading: {
|
||||
color: "#171412",
|
||||
fontSize: "28px",
|
||||
lineHeight: "34px",
|
||||
margin: "0 0 8px",
|
||||
},
|
||||
contact: {
|
||||
color: "#756c62",
|
||||
fontSize: "14px",
|
||||
lineHeight: "21px",
|
||||
margin: "0 0 24px",
|
||||
},
|
||||
messageBox: {
|
||||
backgroundColor: "#171412",
|
||||
borderRadius: "16px",
|
||||
margin: "0 0 24px",
|
||||
padding: "20px",
|
||||
},
|
||||
message: {
|
||||
color: "#fff8eb",
|
||||
fontSize: "16px",
|
||||
lineHeight: "24px",
|
||||
margin: 0,
|
||||
whiteSpace: "pre-wrap" as const,
|
||||
},
|
||||
hr: {
|
||||
borderColor: "#e8dfd0",
|
||||
margin: "24px 0 18px",
|
||||
},
|
||||
sectionTitle: {
|
||||
color: "#171412",
|
||||
fontSize: "15px",
|
||||
fontWeight: 700,
|
||||
margin: "0 0 10px",
|
||||
},
|
||||
metaLine: {
|
||||
color: "#4d4640",
|
||||
fontSize: "14px",
|
||||
lineHeight: "21px",
|
||||
margin: "0 0 6px",
|
||||
},
|
||||
diagnostics: {
|
||||
color: "#4d4640",
|
||||
fontFamily: "Menlo, Consolas, monospace",
|
||||
fontSize: "12px",
|
||||
lineHeight: "18px",
|
||||
margin: 0,
|
||||
whiteSpace: "pre-wrap" as const,
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { createElement, type ReactElement } from "react"
|
||||
import { DownloadLinkEmail, type DownloadLinkEmailProps } from "./download-link.js"
|
||||
import { FeedbackEmail, type FeedbackEmailProps } from "./feedback.js"
|
||||
import { OrganizationInviteEmail, type OrganizationInviteEmailProps } from "./organization-invite.js"
|
||||
import { PasswordResetEmail, type PasswordResetEmailProps } from "./password-reset.js"
|
||||
import { VerificationEmail, type VerificationEmailProps } from "./verification.js"
|
||||
|
||||
export type { DownloadLinkEmailProps } from "./download-link.js"
|
||||
export type { FeedbackEmailProps } from "./feedback.js"
|
||||
export type { OrganizationInviteEmailProps } from "./organization-invite.js"
|
||||
export type { PasswordResetEmailProps } from "./password-reset.js"
|
||||
export type { VerificationEmailProps } from "./verification.js"
|
||||
|
||||
export type EmailTemplateProps = {
|
||||
verification: VerificationEmailProps
|
||||
passwordReset: PasswordResetEmailProps
|
||||
organizationInvite: OrganizationInviteEmailProps
|
||||
downloadLink: DownloadLinkEmailProps
|
||||
feedback: FeedbackEmailProps
|
||||
}
|
||||
|
||||
export type EmailTemplate = keyof EmailTemplateProps
|
||||
|
||||
export const emailSubjects: { [Template in EmailTemplate]: (props: EmailTemplateProps[Template]) => string } = {
|
||||
verification: ({ verificationCode }) => `Your OpenWork verification code is ${verificationCode}`,
|
||||
passwordReset: () => "Reset your OpenWork password",
|
||||
organizationInvite: ({ organizationName }) => `You're invited to join ${organizationName} on OpenWork`,
|
||||
downloadLink: () => "Your OpenWork download link",
|
||||
feedback: ({ name, source }) => `OpenWork feedback from ${name}${source ? ` (${source})` : ""}`,
|
||||
}
|
||||
|
||||
export const emailReplyTo: { [Template in EmailTemplate]: (props: EmailTemplateProps[Template]) => string | undefined } = {
|
||||
verification: () => undefined,
|
||||
passwordReset: () => undefined,
|
||||
organizationInvite: () => undefined,
|
||||
downloadLink: () => undefined,
|
||||
feedback: ({ email }) => email,
|
||||
}
|
||||
|
||||
const emailRenderers: { [Template in EmailTemplate]: (props: EmailTemplateProps[Template]) => ReactElement } = {
|
||||
verification: (props) => createElement(VerificationEmail, props),
|
||||
passwordReset: (props) => createElement(PasswordResetEmail, props),
|
||||
organizationInvite: (props) => createElement(OrganizationInviteEmail, props),
|
||||
downloadLink: (props) => createElement(DownloadLinkEmail, props),
|
||||
feedback: (props) => createElement(FeedbackEmail, props),
|
||||
}
|
||||
|
||||
export function renderEmailTemplate<Template extends EmailTemplate>(
|
||||
template: Template,
|
||||
props: EmailTemplateProps[Template],
|
||||
): ReactElement {
|
||||
return emailRenderers[template](props)
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
import React, { type CSSProperties } from "react"
|
||||
import { Body, Button, Container, Head, Heading, Hr, Html, Img, Preview, Section, Text } from "@react-email/components"
|
||||
|
||||
const LOGO_URL = "https://openworklabs.com/email/openwork-mark.png"
|
||||
|
||||
export type OrganizationInviteEmailProps = {
|
||||
inviteLink: string
|
||||
invitedByName: string
|
||||
invitedByEmail: string
|
||||
organizationName: string
|
||||
role: string
|
||||
}
|
||||
|
||||
export function OrganizationInviteEmail({
|
||||
inviteLink,
|
||||
invitedByName,
|
||||
invitedByEmail,
|
||||
organizationName,
|
||||
role,
|
||||
}: OrganizationInviteEmailProps) {
|
||||
const inviter = invitedByEmail ? `${invitedByName} (${invitedByEmail})` : invitedByName
|
||||
|
||||
return (
|
||||
<Html>
|
||||
<Head />
|
||||
<Preview>{invitedByName} invited you to join {organizationName} on OpenWork</Preview>
|
||||
<Body style={styles.body}>
|
||||
<Container style={styles.frame}>
|
||||
<Section style={styles.brand}>
|
||||
<Img src={LOGO_URL} width="31" height="24" alt="OpenWork" style={styles.brandLogo} />
|
||||
<span style={styles.brandName}>OpenWork</span>
|
||||
</Section>
|
||||
<Section style={styles.card}>
|
||||
<Text style={styles.eyebrow}>Invitation</Text>
|
||||
<Heading style={styles.heading}>Join {organizationName} on OpenWork</Heading>
|
||||
<Text style={styles.text}>
|
||||
{inviter} invited you to join the <span style={styles.strong}>{organizationName}</span> workspace as {articleFor(role)} {role}.
|
||||
</Text>
|
||||
<Button href={inviteLink} style={styles.button}>Accept invite</Button>
|
||||
<Hr style={styles.hr} />
|
||||
<Text style={styles.fallback}>If the button doesn't work, paste this link into your browser:</Text>
|
||||
<Text style={styles.link}>{inviteLink}</Text>
|
||||
</Section>
|
||||
<Text style={styles.footer}>
|
||||
You received this email because someone invited you to an OpenWork workspace.
|
||||
<br />
|
||||
OpenWork · openworklabs.com
|
||||
</Text>
|
||||
</Container>
|
||||
</Body>
|
||||
</Html>
|
||||
)
|
||||
}
|
||||
|
||||
function articleFor(value: string) {
|
||||
return /^[aeiou]/i.test(value.trim()) ? "an" : "a"
|
||||
}
|
||||
|
||||
const styles = {
|
||||
body: {
|
||||
backgroundColor: "#F0F1F3",
|
||||
color: "#1C2024",
|
||||
fontFamily: "'IBM Plex Sans', -apple-system, 'Segoe UI', Helvetica, Arial, sans-serif",
|
||||
margin: 0,
|
||||
},
|
||||
frame: {
|
||||
margin: "0 auto",
|
||||
maxWidth: "560px",
|
||||
padding: "56px 0",
|
||||
},
|
||||
brand: {
|
||||
marginBottom: "20px",
|
||||
paddingLeft: "4px",
|
||||
},
|
||||
brandLogo: {
|
||||
display: "inline-block",
|
||||
verticalAlign: "middle",
|
||||
},
|
||||
brandName: {
|
||||
color: "#1C2024",
|
||||
fontSize: "15px",
|
||||
fontWeight: 600,
|
||||
marginLeft: "8px",
|
||||
verticalAlign: "middle",
|
||||
},
|
||||
card: {
|
||||
backgroundColor: "#FFFFFF",
|
||||
border: "1px solid #E1E4E8",
|
||||
borderRadius: "16px",
|
||||
padding: "40px",
|
||||
},
|
||||
eyebrow: {
|
||||
color: "#60646C",
|
||||
fontSize: "12px",
|
||||
fontWeight: 500,
|
||||
letterSpacing: "0.08em",
|
||||
margin: "0 0 12px",
|
||||
textTransform: "uppercase",
|
||||
},
|
||||
heading: {
|
||||
color: "#1C2024",
|
||||
fontSize: "24px",
|
||||
fontWeight: 600,
|
||||
letterSpacing: "-0.01em",
|
||||
lineHeight: "32px",
|
||||
margin: "0 0 12px",
|
||||
},
|
||||
text: {
|
||||
color: "#60646C",
|
||||
fontSize: "15px",
|
||||
lineHeight: "24px",
|
||||
margin: "0 0 28px",
|
||||
},
|
||||
strong: {
|
||||
color: "#1C2024",
|
||||
fontWeight: 500,
|
||||
},
|
||||
button: {
|
||||
backgroundColor: "#1C2024",
|
||||
borderRadius: "10px",
|
||||
color: "#FFFFFF",
|
||||
display: "inline-block",
|
||||
fontSize: "15px",
|
||||
fontWeight: 500,
|
||||
padding: "12px 24px",
|
||||
textDecoration: "none",
|
||||
},
|
||||
hr: {
|
||||
borderColor: "#E1E4E8",
|
||||
margin: "32px 0 20px",
|
||||
},
|
||||
fallback: {
|
||||
color: "#60646C",
|
||||
fontSize: "13px",
|
||||
lineHeight: "20px",
|
||||
margin: "0 0 4px",
|
||||
},
|
||||
link: {
|
||||
color: "#3E63DD",
|
||||
fontSize: "13px",
|
||||
lineHeight: "20px",
|
||||
margin: 0,
|
||||
wordBreak: "break-all",
|
||||
},
|
||||
footer: {
|
||||
color: "#8B8D98",
|
||||
fontSize: "12px",
|
||||
lineHeight: "18px",
|
||||
margin: "24px 0 0",
|
||||
paddingLeft: "4px",
|
||||
},
|
||||
} satisfies Record<string, CSSProperties>
|
||||
@@ -0,0 +1,91 @@
|
||||
import { Body, Button, Container, Head, Heading, Hr, Html, Preview, Text } from "@react-email/components"
|
||||
|
||||
export type PasswordResetEmailProps = {
|
||||
resetLink: string
|
||||
}
|
||||
|
||||
export function PasswordResetEmail({ resetLink }: PasswordResetEmailProps) {
|
||||
return (
|
||||
<Html>
|
||||
<Head />
|
||||
<Preview>Reset your OpenWork password</Preview>
|
||||
<Body style={styles.body}>
|
||||
<Container style={styles.container}>
|
||||
<Text style={styles.eyebrow}>OpenWork account</Text>
|
||||
<Heading style={styles.heading}>Reset your password</Heading>
|
||||
<Text style={styles.text}>Use this secure link to choose a new password for your OpenWork account.</Text>
|
||||
<Button href={resetLink} style={styles.button}>Reset password</Button>
|
||||
<Text style={styles.footer}>This link expires in 1 hour. If you did not request a password reset, you can ignore this email.</Text>
|
||||
<Hr style={styles.hr} />
|
||||
<Text style={styles.footer}>If the button does not work, paste this link into your browser:</Text>
|
||||
<Text style={styles.link}>{resetLink}</Text>
|
||||
</Container>
|
||||
</Body>
|
||||
</Html>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = {
|
||||
body: {
|
||||
backgroundColor: "#f6f4ef",
|
||||
color: "#171412",
|
||||
fontFamily: "Arial, sans-serif",
|
||||
margin: 0,
|
||||
},
|
||||
container: {
|
||||
backgroundColor: "#fffdf8",
|
||||
border: "1px solid #e8dfd0",
|
||||
borderRadius: "20px",
|
||||
margin: "40px auto",
|
||||
maxWidth: "560px",
|
||||
padding: "32px",
|
||||
},
|
||||
eyebrow: {
|
||||
color: "#8a5a28",
|
||||
fontSize: "13px",
|
||||
fontWeight: 700,
|
||||
letterSpacing: "0.08em",
|
||||
margin: "0 0 12px",
|
||||
textTransform: "uppercase" as const,
|
||||
},
|
||||
heading: {
|
||||
color: "#171412",
|
||||
fontSize: "28px",
|
||||
lineHeight: "34px",
|
||||
margin: "0 0 16px",
|
||||
},
|
||||
text: {
|
||||
color: "#4d4640",
|
||||
fontSize: "16px",
|
||||
lineHeight: "24px",
|
||||
margin: "0 0 24px",
|
||||
},
|
||||
button: {
|
||||
backgroundColor: "#171412",
|
||||
borderRadius: "999px",
|
||||
color: "#fff8eb",
|
||||
display: "inline-block",
|
||||
fontSize: "15px",
|
||||
fontWeight: 700,
|
||||
marginBottom: "24px",
|
||||
padding: "13px 22px",
|
||||
textDecoration: "none",
|
||||
},
|
||||
hr: {
|
||||
borderColor: "#e8dfd0",
|
||||
margin: "28px 0 18px",
|
||||
},
|
||||
footer: {
|
||||
color: "#756c62",
|
||||
fontSize: "14px",
|
||||
lineHeight: "21px",
|
||||
margin: "0 0 8px",
|
||||
},
|
||||
link: {
|
||||
color: "#5b3a18",
|
||||
fontSize: "13px",
|
||||
lineHeight: "19px",
|
||||
margin: 0,
|
||||
wordBreak: "break-all" as const,
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import { Body, Container, Head, Heading, Html, Preview, Section, Text } from "@react-email/components"
|
||||
|
||||
export type VerificationEmailProps = {
|
||||
verificationCode: string
|
||||
}
|
||||
|
||||
export function VerificationEmail({ verificationCode }: VerificationEmailProps) {
|
||||
return (
|
||||
<Html>
|
||||
<Head />
|
||||
<Preview>Your OpenWork verification code is {verificationCode}</Preview>
|
||||
<Body style={styles.body}>
|
||||
<Container style={styles.container}>
|
||||
<Text style={styles.eyebrow}>OpenWork</Text>
|
||||
<Heading style={styles.heading}>Verify your email</Heading>
|
||||
<Text style={styles.text}>Enter this code to finish signing in to OpenWork.</Text>
|
||||
<Section style={styles.codeBox}>
|
||||
<Text style={styles.code}>{verificationCode}</Text>
|
||||
</Section>
|
||||
<Text style={styles.footer}>This code expires in 10 minutes. If you did not request it, you can ignore this email.</Text>
|
||||
</Container>
|
||||
</Body>
|
||||
</Html>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = {
|
||||
body: {
|
||||
backgroundColor: "#f6f4ef",
|
||||
color: "#171412",
|
||||
fontFamily: "Arial, sans-serif",
|
||||
margin: 0,
|
||||
},
|
||||
container: {
|
||||
backgroundColor: "#fffdf8",
|
||||
border: "1px solid #e8dfd0",
|
||||
borderRadius: "20px",
|
||||
margin: "40px auto",
|
||||
maxWidth: "520px",
|
||||
padding: "32px",
|
||||
},
|
||||
eyebrow: {
|
||||
color: "#8a5a28",
|
||||
fontSize: "13px",
|
||||
fontWeight: 700,
|
||||
letterSpacing: "0.08em",
|
||||
margin: "0 0 12px",
|
||||
textTransform: "uppercase" as const,
|
||||
},
|
||||
heading: {
|
||||
color: "#171412",
|
||||
fontSize: "28px",
|
||||
lineHeight: "34px",
|
||||
margin: "0 0 16px",
|
||||
},
|
||||
text: {
|
||||
color: "#4d4640",
|
||||
fontSize: "16px",
|
||||
lineHeight: "24px",
|
||||
margin: "0 0 24px",
|
||||
},
|
||||
codeBox: {
|
||||
backgroundColor: "#171412",
|
||||
borderRadius: "16px",
|
||||
margin: "0 0 24px",
|
||||
padding: "22px",
|
||||
textAlign: "center" as const,
|
||||
},
|
||||
code: {
|
||||
color: "#fff8eb",
|
||||
fontSize: "34px",
|
||||
fontWeight: 700,
|
||||
letterSpacing: "0.18em",
|
||||
lineHeight: "40px",
|
||||
margin: 0,
|
||||
},
|
||||
footer: {
|
||||
color: "#756c62",
|
||||
fontSize: "14px",
|
||||
lineHeight: "21px",
|
||||
margin: 0,
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"jsx": "react-jsx",
|
||||
"skipLibCheck": true,
|
||||
"declaration": true,
|
||||
"outDir": "dist"
|
||||
},
|
||||
"include": ["src", "emails"]
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { defineConfig } from "tsup"
|
||||
|
||||
export default defineConfig({
|
||||
clean: true,
|
||||
dts: true,
|
||||
entry: {
|
||||
index: "src/index.ts",
|
||||
"templates/index": "src/templates/index.ts",
|
||||
},
|
||||
format: ["esm"],
|
||||
target: "es2022",
|
||||
})
|
||||
Reference in New Issue
Block a user