@alfredmouelle/create-stack 0.1.0 → 0.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +101 -40
- package/_stack/apps/next-base/.turbo/turbo-typecheck.log +1 -0
- package/_stack/apps/next-base/.vscode/settings.json +35 -0
- package/_stack/apps/next-base/.zed/settings.json +45 -0
- package/_stack/apps/next-base/src/components/ui/spinner.tsx +1 -1
- package/_stack/apps/next-base/src/emails/components/components.tsx +4 -2
- package/_stack/apps/next-base/src/emails/components/context.tsx +3 -1
- package/_stack/apps/next-base/src/emails/components/theme.ts +6 -7
- package/_stack/apps/next-base/src/env.ts +2 -3
- package/_stack/apps/next-base/src/lib/date.ts +1 -1
- package/_stack/apps/next-base/src/server/auth/guards.ts +1 -1
- package/_stack/apps/next-base/src/server/better-auth/config.ts +1 -1
- package/_stack/apps/next-base/src/server/better-auth/server.ts +2 -2
- package/_stack/apps/next-base/src/server/db/schemas/index.ts +1 -1
- package/_stack/apps/next-base/src/server/db/seed.ts +2 -2
- package/_stack/apps/next-base/src/server/email/adapters/resend/index.ts +3 -3
- package/_stack/apps/next-base/src/server/email/core/address.ts +3 -3
- package/_stack/apps/next-base/src/server/email/core/port.ts +13 -20
- package/_stack/apps/next-base/src/server/email/core/render.ts +2 -2
- package/_stack/apps/next-base/src/server/email/factory.ts +7 -9
- package/_stack/apps/next-base/src/server/email/index.ts +1 -1
- package/_stack/apps/next-base/src/trpc/react.tsx +2 -2
- package/_stack/apps/next-base/src/trpc/server.ts +1 -1
- package/_stack/apps/tanstack-base/.turbo/turbo-typecheck.log +1 -0
- package/_stack/apps/tanstack-base/.vscode/settings.json +35 -0
- package/_stack/apps/tanstack-base/.zed/settings.json +45 -0
- package/_stack/apps/tanstack-base/src/components/form/text-field.tsx +1 -1
- package/_stack/apps/tanstack-base/src/components/ui/spinner.tsx +1 -1
- package/_stack/apps/tanstack-base/src/emails/components/components.tsx +2 -2
- package/_stack/apps/tanstack-base/src/emails/components/context.tsx +1 -1
- package/_stack/apps/tanstack-base/src/emails/components/theme.ts +6 -7
- package/_stack/apps/tanstack-base/src/env.ts +2 -6
- package/_stack/apps/tanstack-base/src/lib/date.ts +1 -1
- package/_stack/apps/tanstack-base/src/routes/__root.tsx +1 -1
- package/_stack/apps/tanstack-base/src/routes/_authed.tsx +1 -4
- package/_stack/apps/tanstack-base/src/routes/api/auth/$.ts +1 -1
- package/_stack/apps/tanstack-base/src/routes/api.trpc.$.tsx +1 -2
- package/_stack/apps/tanstack-base/src/server/better-auth/config.ts +1 -1
- package/_stack/apps/tanstack-base/src/server/better-auth/session.ts +12 -5
- package/_stack/apps/tanstack-base/src/server/db/schemas/index.ts +1 -1
- package/_stack/apps/tanstack-base/src/server/db/seed.ts +2 -2
- package/_stack/apps/tanstack-base/src/server/email/adapters/resend/index.ts +3 -3
- package/_stack/apps/tanstack-base/src/server/email/core/address.ts +3 -3
- package/_stack/apps/tanstack-base/src/server/email/core/port.ts +12 -22
- package/_stack/apps/tanstack-base/src/server/email/core/render.ts +1 -1
- package/_stack/apps/tanstack-base/src/server/email/factory.ts +7 -8
- package/_stack/apps/tanstack-base/src/server/email/index.ts +1 -1
- package/_stack/packages/mailer/src/adapters/brevo/index.ts +3 -3
- package/_stack/packages/mailer/src/adapters/resend/index.ts +3 -3
- package/_stack/packages/mailer/src/adapters/ses/config.ts +3 -3
- package/_stack/packages/mailer/src/adapters/ses/index.ts +4 -5
- package/index.mjs +33 -44
- package/lib/build.mjs +7 -15
- package/lib/env.mjs +5 -6
- package/lib/foundations.mjs +35 -0
- package/lib/identity.mjs +4 -5
- package/lib/mailer.mjs +9 -13
- package/lib/paths.mjs +15 -0
- package/lib/scaffold.mjs +65 -10
- package/lib/strip.mjs +9 -24
- package/lib/util.mjs +8 -9
- package/package.json +1 -1
- package/_stack/packages/analytics/capability.json +0 -26
- package/_stack/packages/cache/capability.json +0 -21
- package/_stack/packages/error-tracking/capability.json +0 -21
- package/_stack/packages/jobs/capability.json +0 -26
- package/_stack/packages/logger/capability.json +0 -21
- package/_stack/packages/mailer/capability.json +0 -28
- package/_stack/packages/storage/capability.json +0 -32
- package/_stack/patterns/README.md +0 -58
- package/_stack/patterns/_baseline/env.ts +0 -31
- package/_stack/patterns/_baseline/tsconfig.json +0 -27
- package/_stack/patterns/better-auth/pattern.json +0 -73
- package/_stack/patterns/better-auth-next/pattern.json +0 -76
- package/_stack/patterns/data-table/pattern.json +0 -43
- package/_stack/patterns/drizzle/pattern.json +0 -61
- package/_stack/patterns/trpc/pattern.json +0 -61
- package/_stack/patterns/trpc-next/pattern.json +0 -64
- package/lib/manifests.mjs +0 -61
- /package/{_stack/patterns/_baseline → templates}/README-author.md +0 -0
- /package/{_stack/patterns/_baseline → templates}/biome.jsonc +0 -0
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
{
|
|
2
|
+
"files.watcherExclude": {
|
|
3
|
+
"**/routeTree.gen.ts": true
|
|
4
|
+
},
|
|
5
|
+
"search.exclude": {
|
|
6
|
+
"**/routeTree.gen.ts": true
|
|
7
|
+
},
|
|
8
|
+
"files.readonlyInclude": {
|
|
9
|
+
"**/routeTree.gen.ts": true
|
|
10
|
+
},
|
|
11
|
+
"[javascript]": {
|
|
12
|
+
"editor.defaultFormatter": "biomejs.biome"
|
|
13
|
+
},
|
|
14
|
+
"[javascriptreact]": {
|
|
15
|
+
"editor.defaultFormatter": "biomejs.biome"
|
|
16
|
+
},
|
|
17
|
+
"[typescript]": {
|
|
18
|
+
"editor.defaultFormatter": "biomejs.biome"
|
|
19
|
+
},
|
|
20
|
+
"[typescriptreact]": {
|
|
21
|
+
"editor.defaultFormatter": "biomejs.biome"
|
|
22
|
+
},
|
|
23
|
+
"[json]": {
|
|
24
|
+
"editor.defaultFormatter": "biomejs.biome"
|
|
25
|
+
},
|
|
26
|
+
"[jsonc]": {
|
|
27
|
+
"editor.defaultFormatter": "biomejs.biome"
|
|
28
|
+
},
|
|
29
|
+
"[css]": {
|
|
30
|
+
"editor.defaultFormatter": "biomejs.biome"
|
|
31
|
+
},
|
|
32
|
+
"editor.codeActionsOnSave": {
|
|
33
|
+
"source.organizeImports.biome": "explicit"
|
|
34
|
+
}
|
|
35
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
{
|
|
2
|
+
"lsp": {
|
|
3
|
+
"biome": {
|
|
4
|
+
"settings": {
|
|
5
|
+
"require_config_file": true,
|
|
6
|
+
"config_path": "biome.jsonc"
|
|
7
|
+
}
|
|
8
|
+
}
|
|
9
|
+
},
|
|
10
|
+
|
|
11
|
+
"languages": {
|
|
12
|
+
"Markdown": {
|
|
13
|
+
"formatter": { "language_server": { "name": "biome" } }
|
|
14
|
+
},
|
|
15
|
+
"JSONC": {
|
|
16
|
+
"formatter": { "language_server": { "name": "biome" } }
|
|
17
|
+
},
|
|
18
|
+
"JSON": {
|
|
19
|
+
"formatter": { "language_server": { "name": "biome" } }
|
|
20
|
+
},
|
|
21
|
+
"HTML": { "formatter": { "language_server": { "name": "biome" } } },
|
|
22
|
+
"CSS": { "formatter": { "language_server": { "name": "biome" } } },
|
|
23
|
+
"JavaScript": {
|
|
24
|
+
"formatter": { "language_server": { "name": "biome" } },
|
|
25
|
+
"code_actions_on_format": {
|
|
26
|
+
"source.fixAll.biome": true,
|
|
27
|
+
"source.organizeImports.biome": true
|
|
28
|
+
}
|
|
29
|
+
},
|
|
30
|
+
"TypeScript": {
|
|
31
|
+
"formatter": { "language_server": { "name": "biome" } },
|
|
32
|
+
"code_actions_on_format": {
|
|
33
|
+
"source.fixAll.biome": true,
|
|
34
|
+
"source.organizeImports.biome": true
|
|
35
|
+
}
|
|
36
|
+
},
|
|
37
|
+
"TSX": {
|
|
38
|
+
"formatter": { "language_server": { "name": "biome" } },
|
|
39
|
+
"code_actions_on_format": {
|
|
40
|
+
"source.fixAll.biome": true,
|
|
41
|
+
"source.organizeImports.biome": true
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
@@ -2,7 +2,7 @@ import { Loader2 } from 'lucide-react'
|
|
|
2
2
|
import type { ComponentProps } from 'react'
|
|
3
3
|
import { cn } from '~/lib/utils'
|
|
4
4
|
|
|
5
|
-
/**
|
|
5
|
+
/** Spinning lucide `loader` icon. */
|
|
6
6
|
export function Spinner({ className, ...props }: ComponentProps<typeof Loader2>) {
|
|
7
7
|
return <Loader2 aria-hidden="true" className={cn('size-4 animate-spin', className)} {...props} />
|
|
8
8
|
}
|
|
@@ -5,9 +5,9 @@ import { createEmailTheme, type EmailTheme, type EmailThemeOverride } from './th
|
|
|
5
5
|
export interface EmailLayoutProps {
|
|
6
6
|
preview: string
|
|
7
7
|
children: React.ReactNode
|
|
8
|
-
/** Per-email theme override, merged onto
|
|
8
|
+
/** Per-email theme override, merged onto default. */
|
|
9
9
|
theme?: EmailTheme | EmailThemeOverride
|
|
10
|
-
/** Footer year
|
|
10
|
+
/** Footer year; pass explicitly for deterministic output. */
|
|
11
11
|
year?: number
|
|
12
12
|
}
|
|
13
13
|
|
|
@@ -12,7 +12,7 @@ export function EmailThemeProvider({ theme, children }: EmailThemeProviderProps)
|
|
|
12
12
|
return <EmailThemeContext.Provider value={theme}>{children}</EmailThemeContext.Provider>
|
|
13
13
|
}
|
|
14
14
|
|
|
15
|
-
/**
|
|
15
|
+
/** Active email theme; falls back to {@link defaultTheme}. */
|
|
16
16
|
export function useEmailTheme(): EmailTheme {
|
|
17
17
|
return useContext(EmailThemeContext)
|
|
18
18
|
}
|
|
@@ -1,13 +1,12 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
4
|
-
* or wrap a tree in `<EmailThemeProvider>`.
|
|
2
|
+
* Swappable email design tokens. Override a subset via {@link createEmailTheme}
|
|
3
|
+
* + `<EmailLayout theme={...}>`, or wrap a tree in `<EmailThemeProvider>`.
|
|
5
4
|
*/
|
|
6
5
|
export interface EmailTheme {
|
|
7
6
|
brand: {
|
|
8
|
-
/**
|
|
7
|
+
/** Header band + footer. */
|
|
9
8
|
name: string
|
|
10
|
-
/** Footer line (
|
|
9
|
+
/** Footer line (year prefixed automatically). */
|
|
11
10
|
footer: string
|
|
12
11
|
}
|
|
13
12
|
fontFamily: string
|
|
@@ -21,7 +20,7 @@ export interface EmailTheme {
|
|
|
21
20
|
border: string
|
|
22
21
|
borderSubtle: string
|
|
23
22
|
destructive: string
|
|
24
|
-
/** Text
|
|
23
|
+
/** Text over accent/destructive buttons. */
|
|
25
24
|
onAccent: string
|
|
26
25
|
}
|
|
27
26
|
}
|
|
@@ -52,7 +51,7 @@ export type EmailThemeOverride = {
|
|
|
52
51
|
colors?: Partial<EmailTheme['colors']>
|
|
53
52
|
}
|
|
54
53
|
|
|
55
|
-
/** Deep-merge
|
|
54
|
+
/** Deep-merge override onto base (defaults to {@link defaultTheme}). */
|
|
56
55
|
export function createEmailTheme(
|
|
57
56
|
override: EmailThemeOverride = {},
|
|
58
57
|
base: EmailTheme = defaultTheme,
|
|
@@ -1,15 +1,11 @@
|
|
|
1
1
|
import { createEnv } from '@t3-oss/env-core'
|
|
2
2
|
import * as v from 'valibot'
|
|
3
3
|
|
|
4
|
-
/**
|
|
4
|
+
/** Required in production, optional in dev/test. */
|
|
5
5
|
export const requiredInProduction = <T extends v.GenericSchema>(schema: T) =>
|
|
6
6
|
process.env.NODE_ENV === 'production' ? schema : v.optional(schema)
|
|
7
7
|
|
|
8
|
-
/**
|
|
9
|
-
* Typed environment. Start minimal — patterns (drizzle, better-auth, …) and
|
|
10
|
-
* capabilities (add-capability) extend the `server` block and `runtimeEnv` with
|
|
11
|
-
* the keys they need.
|
|
12
|
-
*/
|
|
8
|
+
/** Typed env; foundations/capabilities extend `server` + `runtimeEnv` as needed. */
|
|
13
9
|
export const env = createEnv({
|
|
14
10
|
shared: {
|
|
15
11
|
NODE_ENV: v.optional(v.picklist(['development', 'test', 'production']), 'development'),
|
|
@@ -8,7 +8,7 @@ import TanStackQueryDevtools from '~/trpc/devtools'
|
|
|
8
8
|
|
|
9
9
|
import appCss from '../styles.css?url'
|
|
10
10
|
|
|
11
|
-
//
|
|
11
|
+
// Pre-hydration: set theme class to avoid flash of wrong theme.
|
|
12
12
|
const themeScript = `(function(){try{var t=localStorage.getItem('theme')||'system';var m=window.matchMedia('(prefers-color-scheme:dark)').matches;document.documentElement.classList.toggle('dark',t==='dark'||(t==='system'&&m));}catch(e){}})();`
|
|
13
13
|
|
|
14
14
|
interface RouterContext {
|
|
@@ -1,10 +1,7 @@
|
|
|
1
1
|
import { createFileRoute, Outlet, redirect } from '@tanstack/react-router'
|
|
2
2
|
import { getServerSession } from '~/server/better-auth/session'
|
|
3
3
|
|
|
4
|
-
/**
|
|
5
|
-
* Layout group guarding every route under it: redirects to sign-in when there's
|
|
6
|
-
* no session, and exposes the session to child loaders via `beforeLoad`.
|
|
7
|
-
*/
|
|
4
|
+
/** Guards child routes: redirects to sign-in when no session; exposes session via `beforeLoad`. */
|
|
8
5
|
export const Route = createFileRoute('/_authed')({
|
|
9
6
|
beforeLoad: async () => {
|
|
10
7
|
const session = await getServerSession()
|
|
@@ -1,5 +1,4 @@
|
|
|
1
|
-
// Loads
|
|
2
|
-
// (so this server route typechecks even without other react-start imports).
|
|
1
|
+
// Loads TanStack Start augmentation adding `server` to route options (typechecks without other react-start imports).
|
|
3
2
|
|
|
4
3
|
import { createFileRoute } from '@tanstack/react-router'
|
|
5
4
|
import type {} from '@tanstack/react-start'
|
|
@@ -43,7 +43,7 @@ export const auth = betterAuth({
|
|
|
43
43
|
},
|
|
44
44
|
socialProviders,
|
|
45
45
|
user: {
|
|
46
|
-
//
|
|
46
|
+
// Add extra columns here (mirror in auth.schema.ts):
|
|
47
47
|
// additionalFields: {
|
|
48
48
|
// role: { type: 'string', defaultValue: 'user', input: false },
|
|
49
49
|
// },
|
|
@@ -2,8 +2,15 @@ import { createServerFn } from '@tanstack/react-start'
|
|
|
2
2
|
import { getRequest } from '@tanstack/react-start/server'
|
|
3
3
|
import { auth } from '.'
|
|
4
4
|
|
|
5
|
-
/**
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
5
|
+
/**
|
|
6
|
+
* Server-fn resolving the better-auth session from request headers; use in
|
|
7
|
+
* loaders / `beforeLoad`.
|
|
8
|
+
*
|
|
9
|
+
* `getRequest` is called INSIDE the handler on purpose: the Start plugin
|
|
10
|
+
* extracts handlers server-side and strips the import from the client bundle.
|
|
11
|
+
* Calling it at module scope would leak `@tanstack/react-start/server` into
|
|
12
|
+
* client code (routes import this transitively) and trip import-protection.
|
|
13
|
+
*/
|
|
14
|
+
export const getServerSession = createServerFn({ method: 'GET' }).handler(() =>
|
|
15
|
+
auth.api.getSession({ headers: getRequest().headers }),
|
|
16
|
+
)
|
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
//
|
|
1
|
+
// Drizzle schema barrel; one `export *` per `*.schema.ts`.
|
|
2
2
|
export * from './auth.schema'
|
|
@@ -3,11 +3,11 @@ import { config } from 'dotenv'
|
|
|
3
3
|
|
|
4
4
|
config({ path: ['.env.local', '.env'] })
|
|
5
5
|
|
|
6
|
-
// Deterministic
|
|
6
|
+
// Deterministic across runs.
|
|
7
7
|
faker.seed(42)
|
|
8
8
|
|
|
9
9
|
async function main() {
|
|
10
|
-
//
|
|
10
|
+
// Seed your tables here (idempotent) via faker:
|
|
11
11
|
// const { db } = await import('~/server/db')
|
|
12
12
|
// await db.insert(user).values({
|
|
13
13
|
// id: faker.string.uuid(),
|
|
@@ -4,7 +4,7 @@ import { formatAddress } from '../../core/address'
|
|
|
4
4
|
import { type MailerAdapter, MailerError, type RenderedMessage } from '../../core/port'
|
|
5
5
|
import { ResendConfigSchema } from './config'
|
|
6
6
|
|
|
7
|
-
/** Minimal structural view of the Resend client
|
|
7
|
+
/** Minimal structural view of the Resend client (eases testing). */
|
|
8
8
|
export interface ResendClient {
|
|
9
9
|
emails: {
|
|
10
10
|
send(payload: ResendSendPayload): Promise<{
|
|
@@ -30,7 +30,7 @@ interface ResendSendPayload {
|
|
|
30
30
|
|
|
31
31
|
export interface ResendAdapterOptions {
|
|
32
32
|
apiKey: string
|
|
33
|
-
/**
|
|
33
|
+
/** Custom/mock client; defaults to a real `Resend`. */
|
|
34
34
|
client?: ResendClient
|
|
35
35
|
}
|
|
36
36
|
|
|
@@ -39,7 +39,7 @@ function toAttachmentContent(content: Uint8Array | string): Buffer | string {
|
|
|
39
39
|
}
|
|
40
40
|
|
|
41
41
|
export function resendAdapter(options: ResendAdapterOptions): MailerAdapter {
|
|
42
|
-
// Validate
|
|
42
|
+
// Validate early so a missing key fails at construction, not send().
|
|
43
43
|
const config = v.parse(ResendConfigSchema, { apiKey: options.apiKey })
|
|
44
44
|
const client: ResendClient =
|
|
45
45
|
options.client ?? (new Resend(config.apiKey) as unknown as ResendClient)
|
|
@@ -2,7 +2,7 @@ import type { MailAddress, MailRecipient } from './port'
|
|
|
2
2
|
|
|
3
3
|
const ADDRESS_RE = /^\s*(.*?)\s*<([^>]+)>\s*$/
|
|
4
4
|
|
|
5
|
-
/** Coerce a recipient
|
|
5
|
+
/** Coerce a recipient to a structured {@link MailAddress}. */
|
|
6
6
|
export function normalizeAddress(input: MailRecipient): MailAddress {
|
|
7
7
|
if (typeof input !== 'string') return input
|
|
8
8
|
const match = input.match(ADDRESS_RE)
|
|
@@ -10,12 +10,12 @@ export function normalizeAddress(input: MailRecipient): MailAddress {
|
|
|
10
10
|
return { email: input.trim() }
|
|
11
11
|
}
|
|
12
12
|
|
|
13
|
-
/** Coerce a
|
|
13
|
+
/** Coerce a recipient or list to a normalized array. */
|
|
14
14
|
export function normalizeRecipients(input: MailRecipient | MailRecipient[]): MailAddress[] {
|
|
15
15
|
return (Array.isArray(input) ? input : [input]).map(normalizeAddress)
|
|
16
16
|
}
|
|
17
17
|
|
|
18
|
-
/** Render an address
|
|
18
|
+
/** Render an address to RFC-style `Name <email>`. */
|
|
19
19
|
export function formatAddress(address: MailAddress): string {
|
|
20
20
|
return address.name ? `${address.name} <${address.email}>` : address.email
|
|
21
21
|
}
|
|
@@ -1,41 +1,37 @@
|
|
|
1
1
|
import type { ReactElement } from 'react'
|
|
2
2
|
|
|
3
|
-
/**
|
|
3
|
+
/** Structured email address. */
|
|
4
4
|
export interface MailAddress {
|
|
5
5
|
email: string
|
|
6
6
|
name?: string
|
|
7
7
|
}
|
|
8
8
|
|
|
9
|
-
/**
|
|
10
|
-
* A recipient, either as a plain string (`"hi@acme.com"` or
|
|
11
|
-
* `"Acme <hi@acme.com>"`) or a structured {@link MailAddress}.
|
|
12
|
-
*/
|
|
9
|
+
/** Recipient: string (`"hi@acme.com"` / `"Acme <hi@acme.com>"`) or {@link MailAddress}. */
|
|
13
10
|
export type MailRecipient = string | MailAddress
|
|
14
11
|
|
|
15
12
|
export interface MailAttachment {
|
|
16
13
|
filename: string
|
|
17
|
-
/** Raw bytes or
|
|
14
|
+
/** Raw bytes or base64 string. */
|
|
18
15
|
content: Uint8Array | string
|
|
19
16
|
contentType?: string
|
|
20
17
|
}
|
|
21
18
|
|
|
22
19
|
/**
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
* the mailer before it reaches the adapter.
|
|
20
|
+
* Message as authored by app code. Never raw HTML: the body is always a React
|
|
21
|
+
* Email component, rendered to HTML + text by the mailer before the adapter.
|
|
26
22
|
*/
|
|
27
23
|
export interface MailMessage {
|
|
28
24
|
to: MailRecipient | MailRecipient[]
|
|
29
25
|
subject: string
|
|
30
|
-
/**
|
|
26
|
+
/** React Email component; rendered to HTML + text automatically. */
|
|
31
27
|
react: ReactElement
|
|
32
|
-
/** Overrides the default `from
|
|
28
|
+
/** Overrides the mailer's default `from`. */
|
|
33
29
|
from?: MailRecipient
|
|
34
30
|
replyTo?: MailRecipient
|
|
35
31
|
cc?: MailRecipient | MailRecipient[]
|
|
36
32
|
bcc?: MailRecipient | MailRecipient[]
|
|
37
33
|
headers?: Record<string, string>
|
|
38
|
-
/** Provider-agnostic
|
|
34
|
+
/** Provider-agnostic tags for analytics & filtering. */
|
|
39
35
|
tags?: Record<string, string>
|
|
40
36
|
attachments?: MailAttachment[]
|
|
41
37
|
}
|
|
@@ -45,18 +41,12 @@ export interface SentMail {
|
|
|
45
41
|
id: string
|
|
46
42
|
}
|
|
47
43
|
|
|
48
|
-
/**
|
|
49
|
-
* The port the application depends on. Swapping providers means swapping the
|
|
50
|
-
* adapter passed to {@link createMailer}; this interface never changes.
|
|
51
|
-
*/
|
|
44
|
+
/** The port the app depends on; swap providers via {@link createMailer}'s adapter — this never changes. */
|
|
52
45
|
export interface Mailer {
|
|
53
46
|
send(message: MailMessage): Promise<SentMail>
|
|
54
47
|
}
|
|
55
48
|
|
|
56
|
-
/**
|
|
57
|
-
* The message shape an adapter receives: addresses are normalized and the body
|
|
58
|
-
* is already rendered to `html` + `text`. Adapters do no rendering.
|
|
59
|
-
*/
|
|
49
|
+
/** Shape an adapter receives: normalized addresses, body pre-rendered to `html` + `text`. Adapters don't render. */
|
|
60
50
|
export interface RenderedMessage {
|
|
61
51
|
to: MailAddress[]
|
|
62
52
|
from: MailAddress
|
|
@@ -71,13 +61,13 @@ export interface RenderedMessage {
|
|
|
71
61
|
attachments?: MailAttachment[]
|
|
72
62
|
}
|
|
73
63
|
|
|
74
|
-
/**
|
|
64
|
+
/** Contract each provider implements; intentionally tiny. */
|
|
75
65
|
export interface MailerAdapter {
|
|
76
66
|
readonly name: string
|
|
77
67
|
send(message: RenderedMessage): Promise<SentMail>
|
|
78
68
|
}
|
|
79
69
|
|
|
80
|
-
/** Normalized error
|
|
70
|
+
/** Normalized adapter error so callers never catch provider types. */
|
|
81
71
|
export class MailerError extends Error {
|
|
82
72
|
readonly adapter: string
|
|
83
73
|
|
|
@@ -6,7 +6,7 @@ export interface RenderedBody {
|
|
|
6
6
|
text: string
|
|
7
7
|
}
|
|
8
8
|
|
|
9
|
-
/**
|
|
9
|
+
/** Turns a React Email component into HTML + plain text. */
|
|
10
10
|
export type EmailRenderer = (react: ReactElement) => Promise<RenderedBody>
|
|
11
11
|
|
|
12
12
|
/** Default renderer backed by `@react-email/render`. */
|
|
@@ -3,21 +3,20 @@ import type { Mailer, MailerAdapter, MailRecipient, RenderedMessage } from './co
|
|
|
3
3
|
import { type EmailRenderer, renderEmail } from './core/render'
|
|
4
4
|
|
|
5
5
|
export interface CreateMailerOptions {
|
|
6
|
-
/**
|
|
6
|
+
/** Provider impl (Resend, Brevo, …). */
|
|
7
7
|
adapter: MailerAdapter
|
|
8
|
-
/** Default sender
|
|
8
|
+
/** Default sender when a message omits `from`. */
|
|
9
9
|
from: MailRecipient
|
|
10
|
-
/** Override the
|
|
10
|
+
/** Override the renderer (mostly for tests). */
|
|
11
11
|
render?: EmailRenderer
|
|
12
12
|
}
|
|
13
13
|
|
|
14
14
|
/**
|
|
15
|
-
* Build a {@link Mailer}.
|
|
16
|
-
* the
|
|
15
|
+
* Build a {@link Mailer}. Composition root: pick the adapter here; the rest of
|
|
16
|
+
* the app depends only on the `Mailer` port.
|
|
17
17
|
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
* the adapter.
|
|
18
|
+
* Renders the React body to HTML + text, normalizes addresses, applies the
|
|
19
|
+
* default sender, then hands a {@link RenderedMessage} to the adapter.
|
|
21
20
|
*/
|
|
22
21
|
export function createMailer(options: CreateMailerOptions): Mailer {
|
|
23
22
|
const defaultFrom = normalizeAddress(options.from)
|
|
@@ -11,7 +11,7 @@ function required(value: string | undefined, name: string): string {
|
|
|
11
11
|
return value
|
|
12
12
|
}
|
|
13
13
|
|
|
14
|
-
// Lazy so the app boots without RESEND_API_KEY;
|
|
14
|
+
// Lazy so the app boots without RESEND_API_KEY; adapter built on first send.
|
|
15
15
|
let mailer: Mailer | null = null
|
|
16
16
|
function getMailer(): Mailer {
|
|
17
17
|
if (!mailer) {
|
|
@@ -28,7 +28,7 @@ interface BrevoSendResponse {
|
|
|
28
28
|
messageIds?: string[]
|
|
29
29
|
}
|
|
30
30
|
|
|
31
|
-
/**
|
|
31
|
+
/** Structural view of the Brevo client (eases testing). */
|
|
32
32
|
export interface BrevoClientLike {
|
|
33
33
|
transactionalEmails: {
|
|
34
34
|
sendTransacEmail(request: BrevoSendRequest): Promise<BrevoSendResponse>
|
|
@@ -37,7 +37,7 @@ export interface BrevoClientLike {
|
|
|
37
37
|
|
|
38
38
|
export interface BrevoAdapterOptions {
|
|
39
39
|
apiKey: string
|
|
40
|
-
/** Inject
|
|
40
|
+
/** Inject custom/mock client. Defaults to real `BrevoClient`. */
|
|
41
41
|
client?: BrevoClientLike
|
|
42
42
|
}
|
|
43
43
|
|
|
@@ -50,7 +50,7 @@ function toBase64(content: Uint8Array | string): string {
|
|
|
50
50
|
}
|
|
51
51
|
|
|
52
52
|
export function brevoAdapter(options: BrevoAdapterOptions): MailerAdapter {
|
|
53
|
-
// Validate
|
|
53
|
+
// Validate early: missing key fails at construction, not send().
|
|
54
54
|
const config = v.parse(BrevoConfigSchema, { apiKey: options.apiKey })
|
|
55
55
|
const client: BrevoClientLike =
|
|
56
56
|
options.client ?? (new BrevoClient({ apiKey: config.apiKey }) as unknown as BrevoClientLike)
|
|
@@ -4,7 +4,7 @@ import { formatAddress } from '../../core/address.js'
|
|
|
4
4
|
import { type MailerAdapter, MailerError, type RenderedMessage } from '../../core/port.js'
|
|
5
5
|
import { ResendConfigSchema } from './config.js'
|
|
6
6
|
|
|
7
|
-
/**
|
|
7
|
+
/** Structural view of the Resend client (eases testing). */
|
|
8
8
|
export interface ResendClient {
|
|
9
9
|
emails: {
|
|
10
10
|
send(payload: ResendSendPayload): Promise<{
|
|
@@ -30,7 +30,7 @@ interface ResendSendPayload {
|
|
|
30
30
|
|
|
31
31
|
export interface ResendAdapterOptions {
|
|
32
32
|
apiKey: string
|
|
33
|
-
/** Inject
|
|
33
|
+
/** Inject custom/mock client. Defaults to real `Resend`. */
|
|
34
34
|
client?: ResendClient
|
|
35
35
|
}
|
|
36
36
|
|
|
@@ -39,7 +39,7 @@ function toAttachmentContent(content: Uint8Array | string): Buffer | string {
|
|
|
39
39
|
}
|
|
40
40
|
|
|
41
41
|
export function resendAdapter(options: ResendAdapterOptions): MailerAdapter {
|
|
42
|
-
// Validate
|
|
42
|
+
// Validate early: missing key fails at construction, not send().
|
|
43
43
|
const config = v.parse(ResendConfigSchema, { apiKey: options.apiKey })
|
|
44
44
|
const client: ResendClient =
|
|
45
45
|
options.client ?? (new Resend(config.apiKey) as unknown as ResendClient)
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
import * as v from 'valibot'
|
|
2
2
|
|
|
3
3
|
export const SesConfigSchema = v.object({
|
|
4
|
-
/** AWS region. Falls back to
|
|
4
|
+
/** AWS region. Falls back to SDK resolution (e.g. `AWS_REGION`). */
|
|
5
5
|
region: v.optional(v.string()),
|
|
6
|
-
/** Static credentials. Omit to let the SDK resolve
|
|
6
|
+
/** Static credentials. Omit to let the SDK resolve (env, profile, IAM). */
|
|
7
7
|
accessKeyId: v.optional(v.string()),
|
|
8
8
|
secretAccessKey: v.optional(v.string()),
|
|
9
|
-
/** SES configuration set
|
|
9
|
+
/** SES configuration set (event publishing, dedicated IPs, …). */
|
|
10
10
|
configurationSetName: v.optional(v.string()),
|
|
11
11
|
})
|
|
12
12
|
|
|
@@ -5,7 +5,7 @@ import { formatAddress } from '../../core/address.js'
|
|
|
5
5
|
import { type MailerAdapter, MailerError, type RenderedMessage } from '../../core/port.js'
|
|
6
6
|
import { SesConfigSchema } from './config.js'
|
|
7
7
|
|
|
8
|
-
/**
|
|
8
|
+
/** Structural view of the SESv2 client (eases testing). */
|
|
9
9
|
export interface SesClientLike {
|
|
10
10
|
send(command: { input: SesSendEmailInput }): Promise<{ MessageId?: string }>
|
|
11
11
|
}
|
|
@@ -26,12 +26,12 @@ interface SesSendEmailInput {
|
|
|
26
26
|
}
|
|
27
27
|
|
|
28
28
|
export interface SesAdapterOptions {
|
|
29
|
-
/** AWS region. Falls back to
|
|
29
|
+
/** AWS region. Falls back to SDK resolution (e.g. `AWS_REGION`). */
|
|
30
30
|
region?: string
|
|
31
31
|
accessKeyId?: string
|
|
32
32
|
secretAccessKey?: string
|
|
33
33
|
configurationSetName?: string
|
|
34
|
-
/** Inject
|
|
34
|
+
/** Inject custom/mock client. Defaults to real `SESv2Client`. */
|
|
35
35
|
client?: SesClientLike
|
|
36
36
|
}
|
|
37
37
|
|
|
@@ -56,8 +56,7 @@ export function sesAdapter(options: SesAdapterOptions = {}): MailerAdapter {
|
|
|
56
56
|
return {
|
|
57
57
|
name: 'ses',
|
|
58
58
|
async send(message: RenderedMessage) {
|
|
59
|
-
// SES
|
|
60
|
-
// message, which this adapter intentionally doesn't build. Fail loudly.
|
|
59
|
+
// SES Simple content can't carry attachments (needs raw MIME, not built here). Fail loudly.
|
|
61
60
|
if (message.attachments?.length) {
|
|
62
61
|
throw new MailerError('SES adapter does not support attachments (requires raw MIME)', {
|
|
63
62
|
adapter: 'ses',
|