@wervt/nuxt 0.2.13 → 0.2.15
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 +1 -0
- package/dist/module.d.ts +22 -0
- package/dist/module.js +49 -1
- package/dist/provision.d.ts +12 -0
- package/dist/provision.js +35 -1
- package/dist/runtime/app/plugins/feedback.client.d.ts +4 -0
- package/dist/runtime/app/plugins/feedback.client.js +32 -0
- package/dist/runtime/app/useWervtFeedback.d.ts +24 -0
- package/dist/runtime/app/useWervtFeedback.js +21 -0
- package/dist/runtime/email/components.d.ts +130 -0
- package/dist/runtime/email/components.js +255 -0
- package/dist/runtime/email/index.d.ts +3 -0
- package/dist/runtime/email/index.js +3 -0
- package/dist/runtime/email/render.d.ts +17 -0
- package/dist/runtime/email/render.js +64 -0
- package/dist/runtime/server/boot.d.ts +1 -1
- package/dist/runtime/server/email-preview.d.ts +4 -0
- package/dist/runtime/server/email-preview.js +30 -0
- package/dist/runtime/server/index.d.ts +3 -1
- package/dist/runtime/server/index.js +3 -1
- package/dist/runtime/server/mail.d.ts +23 -0
- package/dist/runtime/server/mail.js +43 -0
- package/package.json +7 -1
package/README.md
CHANGED
|
@@ -20,6 +20,7 @@ export default defineNuxtConfig({
|
|
|
20
20
|
- `useWervtUser()`: the signed-in wervt user, on the server and in the app.
|
|
21
21
|
- `useValidatedBody()` / `useValidatedQuery()` / `useValidatedParams()`: valibot validation.
|
|
22
22
|
- `useWervtTrack()`: custom events for wervt's cookieless analytics (page views are counted automatically).
|
|
23
|
+
- `wervt: { feedback: { key } }` and `useWervtFeedback()`: the kontor feedback widget, reports go into a kontor project's triage.
|
|
23
24
|
- `defineTool()` (server): tools in `server/tools/` that browser agents call through WebMCP, and
|
|
24
25
|
MCP clients (Claude Code, Cursor, …) at `/mcp`, signed in through wervt's OAuth login.
|
|
25
26
|
- `nuxt dev` starts against the local wervt stack (Postgres, Electric) and a dev user.
|
package/dist/module.d.ts
CHANGED
|
@@ -30,6 +30,28 @@ export interface ModuleOptions {
|
|
|
30
30
|
* counted by the runtime either way.
|
|
31
31
|
*/
|
|
32
32
|
analytics?: boolean;
|
|
33
|
+
/**
|
|
34
|
+
* The kontor feedback widget: a "Feedback" button that reports issues into a kontor project's
|
|
35
|
+
* triage, as the signed-in wervt user when the app's URL is an allowed origin of the key.
|
|
36
|
+
* With `true`, the key comes from `wervt env <app> --set NUXT_PUBLIC_WERVT_FEEDBACK_KEY=pk_…`
|
|
37
|
+
* (it can override a configured key the same way).
|
|
38
|
+
*/
|
|
39
|
+
feedback?: FeedbackOptions | boolean;
|
|
40
|
+
}
|
|
41
|
+
export interface FeedbackOptions {
|
|
42
|
+
/** Public intake key of the kontor project (Project settings → Intake keys). */
|
|
43
|
+
key?: string;
|
|
44
|
+
/** The kontor instance. Defaults to https://kontor.wervt.app. */
|
|
45
|
+
url?: string;
|
|
46
|
+
/** Widget language. Defaults to the browser's. */
|
|
47
|
+
lang?: 'de' | 'en';
|
|
48
|
+
/** `none` hides the floating button; open the form with `useWervtFeedback().open()`. */
|
|
49
|
+
position?: 'bottom-right' | 'bottom-left' | 'none';
|
|
50
|
+
label?: string;
|
|
51
|
+
/** Accent color (any CSS color). */
|
|
52
|
+
color?: string;
|
|
53
|
+
/** Also load it in `nuxt dev`. Off by default: localhost usually isn't an allowed origin. */
|
|
54
|
+
dev?: boolean;
|
|
33
55
|
}
|
|
34
56
|
export interface McpOptions {
|
|
35
57
|
/** Server name clients show. Defaults to the app name. */
|
package/dist/module.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { resolveAppName, setupDev } from "./dev.js";
|
|
2
|
-
import { addImports, addPlugin, addServerHandler, addServerPlugin, addServerTemplate, createResolver, defineNuxtModule, getNuxtVersion, hasNuxtModule, installModule, useLogger } from "nuxt/kit";
|
|
2
|
+
import { addImports, addPlugin, addServerHandler, addServerPlugin, addServerTemplate, addTypeTemplate, createResolver, defineNuxtModule, getNuxtVersion, hasNuxtModule, installModule, useLogger } from "nuxt/kit";
|
|
3
3
|
import { existsSync } from "node:fs";
|
|
4
4
|
import { mkdir, readFile, readdir, writeFile } from "node:fs/promises";
|
|
5
5
|
import { builtinModules, createRequire } from "node:module";
|
|
@@ -228,6 +228,21 @@ var module_default = defineNuxtModule({
|
|
|
228
228
|
const app = await resolveAppName(nuxt.options.rootDir, options.app);
|
|
229
229
|
await setupDev(nuxt, app, options.dev ?? {}, { storage: useAblage });
|
|
230
230
|
}
|
|
231
|
+
addTypeTemplate({
|
|
232
|
+
filename: "types/wervt-email.d.ts",
|
|
233
|
+
getContents: () => "declare module '*.vue' {\n import type { DefineComponent } from 'vue'\n const component: DefineComponent<Record<string, unknown>>\n export default component\n}\n"
|
|
234
|
+
}, { nitro: true });
|
|
235
|
+
if (nuxt.options.dev) {
|
|
236
|
+
const templates = await findEmailTemplates([join(nuxt.options.srcDir, "emails"), join(nuxt.options.rootDir, "emails")]);
|
|
237
|
+
addServerTemplate({
|
|
238
|
+
filename: "#wervt/emails",
|
|
239
|
+
getContents: () => `export const templates = {\n${templates.map(({ name, path }) => ` ${JSON.stringify(name)}: () => import(${JSON.stringify(path)}),`).join("\n")}\n}\n`
|
|
240
|
+
});
|
|
241
|
+
for (const route of ["/_wervt/emails", "/_wervt/emails/**"]) addServerHandler({
|
|
242
|
+
route,
|
|
243
|
+
handler: resolver.resolve("./runtime/server/email-preview")
|
|
244
|
+
});
|
|
245
|
+
}
|
|
231
246
|
if (nuxt.options.dev) for (const route of ["/_wervt/login", "/_wervt/logout"]) addServerHandler({
|
|
232
247
|
route,
|
|
233
248
|
handler: resolver.resolve("./runtime/server/dev-auth")
|
|
@@ -265,6 +280,24 @@ var module_default = defineNuxtModule({
|
|
|
265
280
|
mode: "client"
|
|
266
281
|
});
|
|
267
282
|
}
|
|
283
|
+
const feedbackOptions = options.feedback === true ? {} : options.feedback;
|
|
284
|
+
if (feedbackOptions && (feedbackOptions.dev || !nuxt.options.dev)) {
|
|
285
|
+
const { dev: _, ...feedback } = feedbackOptions;
|
|
286
|
+
const config = nuxt.options.runtimeConfig.public;
|
|
287
|
+
config.wervt = {
|
|
288
|
+
...config.wervt,
|
|
289
|
+
feedback: {
|
|
290
|
+
key: "",
|
|
291
|
+
url: "https://kontor.wervt.app",
|
|
292
|
+
...feedback,
|
|
293
|
+
app: await resolveAppName(nuxt.options.rootDir, options.app)
|
|
294
|
+
}
|
|
295
|
+
};
|
|
296
|
+
addPlugin({
|
|
297
|
+
src: resolver.resolve("./runtime/app/plugins/feedback.client"),
|
|
298
|
+
mode: "client"
|
|
299
|
+
});
|
|
300
|
+
}
|
|
268
301
|
nuxt.options.build.transpile.push(resolver.resolve("./runtime"));
|
|
269
302
|
addImports([
|
|
270
303
|
{
|
|
@@ -290,6 +323,10 @@ var module_default = defineNuxtModule({
|
|
|
290
323
|
{
|
|
291
324
|
name: "useWervtUpdate",
|
|
292
325
|
from: resolver.resolve("./runtime/app/useWervtUpdate")
|
|
326
|
+
},
|
|
327
|
+
{
|
|
328
|
+
name: "useWervtFeedback",
|
|
329
|
+
from: resolver.resolve("./runtime/app/useWervtFeedback")
|
|
293
330
|
}
|
|
294
331
|
]);
|
|
295
332
|
}
|
|
@@ -302,6 +339,17 @@ const TOOL_EXTENSIONS = /* @__PURE__ */ new Set([
|
|
|
302
339
|
]);
|
|
303
340
|
const TOOL_NAME_RE = /^[\w.-]{1,128}$/;
|
|
304
341
|
/** The tools in `server/tools/`: `todos/add.ts` is `todos.add`, like Nitro names tasks. */
|
|
342
|
+
async function findEmailTemplates(dirs) {
|
|
343
|
+
const templates = /* @__PURE__ */ new Map();
|
|
344
|
+
for (const dir of new Set(dirs)) {
|
|
345
|
+
if (!existsSync(dir)) continue;
|
|
346
|
+
for (const file of await readdir(dir)) if (extname(file) === ".vue") templates.set(file.slice(0, -4), join(dir, file));
|
|
347
|
+
}
|
|
348
|
+
return [...templates].map(([name, path]) => ({
|
|
349
|
+
name,
|
|
350
|
+
path
|
|
351
|
+
}));
|
|
352
|
+
}
|
|
305
353
|
async function findTools(dir) {
|
|
306
354
|
const files = await readdir(dir, { recursive: true }).catch(() => []);
|
|
307
355
|
const tools = [];
|
package/dist/provision.d.ts
CHANGED
|
@@ -28,6 +28,18 @@ export declare function appDatabaseUrl({ app, adminUrl, secret }: ProvisionOptio
|
|
|
28
28
|
* only connect to the wervt database.
|
|
29
29
|
*/
|
|
30
30
|
export declare function provisionDatabase(options: ProvisionOptions): Promise<void>;
|
|
31
|
+
/** The role PgBouncer signs in with to look up app roles' password hashes (`auth_query`). */
|
|
32
|
+
export declare const POOLER_ROLE = "pgbouncer";
|
|
33
|
+
/**
|
|
34
|
+
* Prepares Postgres for PgBouncer in front of the app isolates: a login role for PgBouncer and
|
|
35
|
+
* `pgbouncer.get_auth()`, which hands out the SCRAM hashes of app roles (`app_*`) only, never
|
|
36
|
+
* those of the superuser or other roles. App passwords are derived, not stored anywhere
|
|
37
|
+
* PgBouncer could read them, so it has to ask Postgres.
|
|
38
|
+
*/
|
|
39
|
+
export declare function setupPooler({ adminUrl, password }: {
|
|
40
|
+
adminUrl: string;
|
|
41
|
+
password: string;
|
|
42
|
+
}): Promise<void>;
|
|
31
43
|
/** Drops the app's schema (all its data) and role. */
|
|
32
44
|
export declare function dropDatabase({ app, adminUrl }: Pick<ProvisionOptions, 'app' | 'adminUrl'>): Promise<void>;
|
|
33
45
|
//#endregion
|
package/dist/provision.js
CHANGED
|
@@ -108,6 +108,40 @@ async function claimSchema(admin, role) {
|
|
|
108
108
|
where n.nspname = ${role} and p.proowner <> ${role}::regrole`;
|
|
109
109
|
for (const { kind, name } of objects) await admin.unsafe(`alter ${kind} ${name} owner to "${role}"`);
|
|
110
110
|
}
|
|
111
|
+
/** The role PgBouncer signs in with to look up app roles' password hashes (`auth_query`). */
|
|
112
|
+
const POOLER_ROLE = "pgbouncer";
|
|
113
|
+
/**
|
|
114
|
+
* Prepares Postgres for PgBouncer in front of the app isolates: a login role for PgBouncer and
|
|
115
|
+
* `pgbouncer.get_auth()`, which hands out the SCRAM hashes of app roles (`app_*`) only, never
|
|
116
|
+
* those of the superuser or other roles. App passwords are derived, not stored anywhere
|
|
117
|
+
* PgBouncer could read them, so it has to ask Postgres.
|
|
118
|
+
*/
|
|
119
|
+
async function setupPooler({ adminUrl, password }) {
|
|
120
|
+
const database = new URL(adminUrl).pathname.slice(1);
|
|
121
|
+
const admin = postgres(adminUrl, {
|
|
122
|
+
max: 1,
|
|
123
|
+
onnotice: () => {}
|
|
124
|
+
});
|
|
125
|
+
try {
|
|
126
|
+
const [row] = await admin`select exists(select 1 from pg_roles where rolname = ${POOLER_ROLE})`;
|
|
127
|
+
await admin.unsafe(`${row?.exists ? "alter" : "create"} role "${POOLER_ROLE}" login password '${password.replaceAll("'", "''")}'`);
|
|
128
|
+
await admin.unsafe(`grant connect on database "${database}" to "${POOLER_ROLE}"`);
|
|
129
|
+
await admin.unsafe(`create schema if not exists "${POOLER_ROLE}"`);
|
|
130
|
+
await admin.unsafe(`revoke all on schema "${POOLER_ROLE}" from public`);
|
|
131
|
+
await admin.unsafe(`grant usage on schema "${POOLER_ROLE}" to "${POOLER_ROLE}"`);
|
|
132
|
+
await admin.unsafe(`
|
|
133
|
+
create or replace function "${POOLER_ROLE}".get_auth(p_usename text)
|
|
134
|
+
returns table(usename name, passwd text)
|
|
135
|
+
language sql security definer set search_path = pg_catalog as $$
|
|
136
|
+
select usename, passwd from pg_shadow
|
|
137
|
+
where usename = p_usename and usename like 'app_%' and not usesuper
|
|
138
|
+
$$`);
|
|
139
|
+
await admin.unsafe(`revoke all on function "${POOLER_ROLE}".get_auth(text) from public`);
|
|
140
|
+
await admin.unsafe(`grant execute on function "${POOLER_ROLE}".get_auth(text) to "${POOLER_ROLE}"`);
|
|
141
|
+
} finally {
|
|
142
|
+
await admin.end();
|
|
143
|
+
}
|
|
144
|
+
}
|
|
111
145
|
/** Drops the app's schema (all its data) and role. */
|
|
112
146
|
async function dropDatabase({ app, adminUrl }) {
|
|
113
147
|
const role = dbRole(app);
|
|
@@ -129,4 +163,4 @@ async function dropDatabase({ app, adminUrl }) {
|
|
|
129
163
|
}
|
|
130
164
|
}
|
|
131
165
|
//#endregion
|
|
132
|
-
export { StorageError, appDatabaseUrl, createStorage, dbPassword, dbRole, dropDatabase, hmac, provisionDatabase, storageCredentials };
|
|
166
|
+
export { POOLER_ROLE, StorageError, appDatabaseUrl, createStorage, dbPassword, dbRole, dropDatabase, hmac, provisionDatabase, setupPooler, storageCredentials };
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { pendingFeedbackContext } from "../useWervtFeedback.js";
|
|
2
|
+
import { defineNuxtPlugin, onNuxtReady, useRuntimeConfig } from "#app";
|
|
3
|
+
//#region src/runtime/app/plugins/feedback.client.ts
|
|
4
|
+
var feedback_client_default = defineNuxtPlugin({
|
|
5
|
+
name: "wervt:feedback",
|
|
6
|
+
setup() {
|
|
7
|
+
const config = useRuntimeConfig();
|
|
8
|
+
const feedback = config.public.wervt?.feedback;
|
|
9
|
+
if (!feedback?.key) return;
|
|
10
|
+
onNuxtReady(() => {
|
|
11
|
+
const script = document.createElement("script");
|
|
12
|
+
script.src = `${feedback.url.replace(/\/$/, "")}/widget.js`;
|
|
13
|
+
script.async = true;
|
|
14
|
+
script.dataset.key = feedback.key;
|
|
15
|
+
for (const name of [
|
|
16
|
+
"lang",
|
|
17
|
+
"position",
|
|
18
|
+
"label",
|
|
19
|
+
"color"
|
|
20
|
+
]) if (feedback[name]) script.dataset[name] = feedback[name];
|
|
21
|
+
script.dataset.context = JSON.stringify({
|
|
22
|
+
app: feedback.app,
|
|
23
|
+
release: config.app.buildId,
|
|
24
|
+
...pendingFeedbackContext
|
|
25
|
+
});
|
|
26
|
+
script.addEventListener("load", () => window.kontor?.setContext(pendingFeedbackContext));
|
|
27
|
+
document.head.appendChild(script);
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
});
|
|
31
|
+
//#endregion
|
|
32
|
+
export { feedback_client_default as default };
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
//#region src/runtime/app/useWervtFeedback.d.ts
|
|
2
|
+
interface KontorWidget {
|
|
3
|
+
open(): void;
|
|
4
|
+
close(): void;
|
|
5
|
+
setContext(values: Record<string, unknown>): void;
|
|
6
|
+
}
|
|
7
|
+
declare global {
|
|
8
|
+
interface Window {
|
|
9
|
+
kontor?: KontorWidget;
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
export declare const pendingFeedbackContext: Record<string, unknown>;
|
|
13
|
+
/**
|
|
14
|
+
* Controls the feedback widget (`wervt: { feedback: { key } }`): open it from your own button,
|
|
15
|
+
* or add context to every report, e.g. what the user was looking at.
|
|
16
|
+
*
|
|
17
|
+
* @example useWervtFeedback().setContext({ room: room.id })
|
|
18
|
+
*/
|
|
19
|
+
export declare function useWervtFeedback(): {
|
|
20
|
+
open: () => void | undefined;
|
|
21
|
+
close: () => void | undefined;
|
|
22
|
+
setContext(values: Record<string, unknown>): void;
|
|
23
|
+
};
|
|
24
|
+
//#endregion
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
//#region src/runtime/app/useWervtFeedback.ts
|
|
2
|
+
const pendingFeedbackContext = {};
|
|
3
|
+
/**
|
|
4
|
+
* Controls the feedback widget (`wervt: { feedback: { key } }`): open it from your own button,
|
|
5
|
+
* or add context to every report, e.g. what the user was looking at.
|
|
6
|
+
*
|
|
7
|
+
* @example useWervtFeedback().setContext({ room: room.id })
|
|
8
|
+
*/
|
|
9
|
+
function useWervtFeedback() {
|
|
10
|
+
const widget = () => import.meta.client ? window.kontor : void 0;
|
|
11
|
+
return {
|
|
12
|
+
open: () => widget()?.open(),
|
|
13
|
+
close: () => widget()?.close(),
|
|
14
|
+
setContext(values) {
|
|
15
|
+
Object.assign(pendingFeedbackContext, values);
|
|
16
|
+
widget()?.setContext(values);
|
|
17
|
+
}
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
//#endregion
|
|
21
|
+
export { pendingFeedbackContext, useWervtFeedback };
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { InjectionKey } from "vue";
|
|
2
|
+
//#region src/runtime/email/components.d.ts
|
|
3
|
+
/** The template's subject, handed to renderEmail() while rendering. */
|
|
4
|
+
export declare const SUBJECT_KEY: InjectionKey<(subject: string) => void>;
|
|
5
|
+
export declare const emailTheme: {
|
|
6
|
+
page: string;
|
|
7
|
+
card: string;
|
|
8
|
+
border: string;
|
|
9
|
+
text: string;
|
|
10
|
+
highlighted: string;
|
|
11
|
+
muted: string;
|
|
12
|
+
dimmed: string;
|
|
13
|
+
button: string;
|
|
14
|
+
buttonText: string;
|
|
15
|
+
codeBackground: string;
|
|
16
|
+
radius: string;
|
|
17
|
+
font: string;
|
|
18
|
+
mono: string;
|
|
19
|
+
};
|
|
20
|
+
/**
|
|
21
|
+
* The whole email: document, preheader, card and footer. Put it at the root of every template.
|
|
22
|
+
*
|
|
23
|
+
* ```vue
|
|
24
|
+
* <WervtEmail subject="Your code" app="kontor"> … </WervtEmail>
|
|
25
|
+
* ```
|
|
26
|
+
*/
|
|
27
|
+
export declare const WervtEmail: import("vue").DefineComponent<import("vue").ExtractPropTypes<{
|
|
28
|
+
/** The email's subject line. */
|
|
29
|
+
subject: {
|
|
30
|
+
type: StringConstructor;
|
|
31
|
+
required: true;
|
|
32
|
+
};
|
|
33
|
+
/** Shown above the card and in the footer, e.g. the app's name. */
|
|
34
|
+
app: {
|
|
35
|
+
type: StringConstructor;
|
|
36
|
+
default: string;
|
|
37
|
+
};
|
|
38
|
+
/** The preview line inboxes show after the subject. */
|
|
39
|
+
preview: {
|
|
40
|
+
type: StringConstructor;
|
|
41
|
+
default: string;
|
|
42
|
+
};
|
|
43
|
+
/** Replaces the footer line ("Sent by <app> on wervt."). */
|
|
44
|
+
footer: {
|
|
45
|
+
type: StringConstructor;
|
|
46
|
+
default: string;
|
|
47
|
+
};
|
|
48
|
+
}>, () => import("vue").VNode<import("vue").RendererNode, import("vue").RendererElement, {
|
|
49
|
+
[key: string]: any;
|
|
50
|
+
}>, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {}, string, import("vue").PublicProps, Readonly<import("vue").ExtractPropTypes<{
|
|
51
|
+
/** The email's subject line. */
|
|
52
|
+
subject: {
|
|
53
|
+
type: StringConstructor;
|
|
54
|
+
required: true;
|
|
55
|
+
};
|
|
56
|
+
/** Shown above the card and in the footer, e.g. the app's name. */
|
|
57
|
+
app: {
|
|
58
|
+
type: StringConstructor;
|
|
59
|
+
default: string;
|
|
60
|
+
};
|
|
61
|
+
/** The preview line inboxes show after the subject. */
|
|
62
|
+
preview: {
|
|
63
|
+
type: StringConstructor;
|
|
64
|
+
default: string;
|
|
65
|
+
};
|
|
66
|
+
/** Replaces the footer line ("Sent by <app> on wervt."). */
|
|
67
|
+
footer: {
|
|
68
|
+
type: StringConstructor;
|
|
69
|
+
default: string;
|
|
70
|
+
};
|
|
71
|
+
}>> & Readonly<{}>, {
|
|
72
|
+
app: string;
|
|
73
|
+
preview: string;
|
|
74
|
+
footer: string;
|
|
75
|
+
}, {}, {}, {}, string, import("vue").ComponentProvideOptions, true, {}, any>;
|
|
76
|
+
export declare const EmailHeading: import("vue").DefineComponent<{}, () => import("vue").VNode<import("vue").RendererNode, import("vue").RendererElement, {
|
|
77
|
+
[key: string]: any;
|
|
78
|
+
}>, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {}, string, import("vue").PublicProps, Readonly<{}> & Readonly<{}>, {}, {}, {}, {}, string, import("vue").ComponentProvideOptions, true, {}, any>;
|
|
79
|
+
export declare const EmailText: import("vue").DefineComponent<{}, () => import("vue").VNode<import("vue").RendererNode, import("vue").RendererElement, {
|
|
80
|
+
[key: string]: any;
|
|
81
|
+
}>, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {}, string, import("vue").PublicProps, Readonly<{}> & Readonly<{}>, {}, {}, {}, {}, string, import("vue").ComponentProvideOptions, true, {}, any>;
|
|
82
|
+
/** Secondary text: hints, "ignore this if …". */
|
|
83
|
+
export declare const EmailMuted: import("vue").DefineComponent<{}, () => import("vue").VNode<import("vue").RendererNode, import("vue").RendererElement, {
|
|
84
|
+
[key: string]: any;
|
|
85
|
+
}>, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {}, string, import("vue").PublicProps, Readonly<{}> & Readonly<{}>, {}, {}, {}, {}, string, import("vue").ComponentProvideOptions, true, {}, any>;
|
|
86
|
+
export declare const EmailLink: import("vue").DefineComponent<import("vue").ExtractPropTypes<{
|
|
87
|
+
href: {
|
|
88
|
+
type: StringConstructor;
|
|
89
|
+
required: true;
|
|
90
|
+
};
|
|
91
|
+
}>, () => import("vue").VNode<import("vue").RendererNode, import("vue").RendererElement, {
|
|
92
|
+
[key: string]: any;
|
|
93
|
+
}>, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {}, string, import("vue").PublicProps, Readonly<import("vue").ExtractPropTypes<{
|
|
94
|
+
href: {
|
|
95
|
+
type: StringConstructor;
|
|
96
|
+
required: true;
|
|
97
|
+
};
|
|
98
|
+
}>> & Readonly<{}>, {}, {}, {}, {}, string, import("vue").ComponentProvideOptions, true, {}, any>;
|
|
99
|
+
/** A call to action. Bulletproof: a table cell carries the background, so Outlook shows it too. */
|
|
100
|
+
export declare const EmailButton: import("vue").DefineComponent<import("vue").ExtractPropTypes<{
|
|
101
|
+
href: {
|
|
102
|
+
type: StringConstructor;
|
|
103
|
+
required: true;
|
|
104
|
+
};
|
|
105
|
+
}>, () => import("vue").VNode<import("vue").RendererNode, import("vue").RendererElement, {
|
|
106
|
+
[key: string]: any;
|
|
107
|
+
}>, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {}, string, import("vue").PublicProps, Readonly<import("vue").ExtractPropTypes<{
|
|
108
|
+
href: {
|
|
109
|
+
type: StringConstructor;
|
|
110
|
+
required: true;
|
|
111
|
+
};
|
|
112
|
+
}>> & Readonly<{}>, {}, {}, {}, {}, string, import("vue").ComponentProvideOptions, true, {}, any>;
|
|
113
|
+
/** A one-time code, large and spaced so it reads (and copies) easily. */
|
|
114
|
+
export declare const EmailCode: import("vue").DefineComponent<import("vue").ExtractPropTypes<{
|
|
115
|
+
code: {
|
|
116
|
+
type: StringConstructor;
|
|
117
|
+
required: true;
|
|
118
|
+
};
|
|
119
|
+
}>, () => import("vue").VNode<import("vue").RendererNode, import("vue").RendererElement, {
|
|
120
|
+
[key: string]: any;
|
|
121
|
+
}>, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {}, string, import("vue").PublicProps, Readonly<import("vue").ExtractPropTypes<{
|
|
122
|
+
code: {
|
|
123
|
+
type: StringConstructor;
|
|
124
|
+
required: true;
|
|
125
|
+
};
|
|
126
|
+
}>> & Readonly<{}>, {}, {}, {}, {}, string, import("vue").ComponentProvideOptions, true, {}, any>;
|
|
127
|
+
export declare const EmailDivider: import("vue").DefineComponent<{}, () => import("vue").VNode<import("vue").RendererNode, import("vue").RendererElement, {
|
|
128
|
+
[key: string]: any;
|
|
129
|
+
}>, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {}, string, import("vue").PublicProps, Readonly<{}> & Readonly<{}>, {}, {}, {}, {}, string, import("vue").ComponentProvideOptions, true, {}, any>;
|
|
130
|
+
//#endregion
|
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
import { defineComponent, h, inject } from "vue";
|
|
2
|
+
//#region src/runtime/email/components.ts
|
|
3
|
+
/** The template's subject, handed to renderEmail() while rendering. */
|
|
4
|
+
const SUBJECT_KEY = Symbol("wervt-email-subject");
|
|
5
|
+
const emailTheme = {
|
|
6
|
+
page: "#fafafa",
|
|
7
|
+
card: "#ffffff",
|
|
8
|
+
border: "#ebebeb",
|
|
9
|
+
text: "#171717",
|
|
10
|
+
highlighted: "#0a0a0a",
|
|
11
|
+
muted: "#666666",
|
|
12
|
+
dimmed: "#8f8f8f",
|
|
13
|
+
button: "#171717",
|
|
14
|
+
buttonText: "#ffffff",
|
|
15
|
+
codeBackground: "#f2f2f2",
|
|
16
|
+
radius: "6px",
|
|
17
|
+
font: "Poppins, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif",
|
|
18
|
+
mono: "'Geist Mono', ui-monospace, SFMono-Regular, Menlo, Consolas, monospace"
|
|
19
|
+
};
|
|
20
|
+
const t = emailTheme;
|
|
21
|
+
/**
|
|
22
|
+
* The whole email: document, preheader, card and footer. Put it at the root of every template.
|
|
23
|
+
*
|
|
24
|
+
* ```vue
|
|
25
|
+
* <WervtEmail subject="Your code" app="kontor"> … </WervtEmail>
|
|
26
|
+
* ```
|
|
27
|
+
*/
|
|
28
|
+
const WervtEmail = defineComponent({
|
|
29
|
+
name: "WervtEmail",
|
|
30
|
+
props: {
|
|
31
|
+
/** The email's subject line. */
|
|
32
|
+
subject: {
|
|
33
|
+
type: String,
|
|
34
|
+
required: true
|
|
35
|
+
},
|
|
36
|
+
/** Shown above the card and in the footer, e.g. the app's name. */
|
|
37
|
+
app: {
|
|
38
|
+
type: String,
|
|
39
|
+
default: "wervt"
|
|
40
|
+
},
|
|
41
|
+
/** The preview line inboxes show after the subject. */
|
|
42
|
+
preview: {
|
|
43
|
+
type: String,
|
|
44
|
+
default: ""
|
|
45
|
+
},
|
|
46
|
+
/** Replaces the footer line ("Sent by <app> on wervt."). */
|
|
47
|
+
footer: {
|
|
48
|
+
type: String,
|
|
49
|
+
default: ""
|
|
50
|
+
}
|
|
51
|
+
},
|
|
52
|
+
setup(props, { slots }) {
|
|
53
|
+
inject(SUBJECT_KEY, void 0)?.(props.subject);
|
|
54
|
+
return () => h("html", { lang: "en" }, [h("head", [
|
|
55
|
+
h("meta", { charset: "utf-8" }),
|
|
56
|
+
h("meta", {
|
|
57
|
+
name: "viewport",
|
|
58
|
+
content: "width=device-width, initial-scale=1"
|
|
59
|
+
}),
|
|
60
|
+
h("meta", {
|
|
61
|
+
name: "color-scheme",
|
|
62
|
+
content: "light"
|
|
63
|
+
}),
|
|
64
|
+
h("meta", {
|
|
65
|
+
name: "supported-color-schemes",
|
|
66
|
+
content: "light"
|
|
67
|
+
}),
|
|
68
|
+
h("title", props.subject),
|
|
69
|
+
h("link", {
|
|
70
|
+
rel: "stylesheet",
|
|
71
|
+
href: "https://fonts.googleapis.com/css2?family=Poppins:wght@400;500;600&display=swap"
|
|
72
|
+
})
|
|
73
|
+
]), h("body", { style: {
|
|
74
|
+
margin: 0,
|
|
75
|
+
padding: 0,
|
|
76
|
+
background: t.page
|
|
77
|
+
} }, [props.preview ? h("div", {
|
|
78
|
+
class: "wervt-preheader",
|
|
79
|
+
style: {
|
|
80
|
+
display: "none",
|
|
81
|
+
overflow: "hidden",
|
|
82
|
+
maxHeight: 0,
|
|
83
|
+
maxWidth: 0,
|
|
84
|
+
opacity: 0,
|
|
85
|
+
lineHeight: "1px",
|
|
86
|
+
fontSize: "1px",
|
|
87
|
+
color: t.page
|
|
88
|
+
}
|
|
89
|
+
}, props.preview) : null, table({
|
|
90
|
+
width: "100%",
|
|
91
|
+
style: { background: t.page }
|
|
92
|
+
}, [h("tr", [h("td", {
|
|
93
|
+
align: "center",
|
|
94
|
+
style: { padding: "40px 16px" }
|
|
95
|
+
}, table({
|
|
96
|
+
width: "100%",
|
|
97
|
+
style: { maxWidth: "560px" }
|
|
98
|
+
}, [
|
|
99
|
+
h("tr", [h("td", { style: {
|
|
100
|
+
padding: "0 4px 16px",
|
|
101
|
+
fontFamily: t.font,
|
|
102
|
+
fontSize: "14px",
|
|
103
|
+
fontWeight: 600,
|
|
104
|
+
color: t.highlighted
|
|
105
|
+
} }, props.app)]),
|
|
106
|
+
h("tr", [h("td", { style: {
|
|
107
|
+
background: t.card,
|
|
108
|
+
border: `1px solid ${t.border}`,
|
|
109
|
+
borderRadius: t.radius,
|
|
110
|
+
padding: "32px",
|
|
111
|
+
fontFamily: t.font,
|
|
112
|
+
color: t.text
|
|
113
|
+
} }, slots.default?.())]),
|
|
114
|
+
h("tr", [h("td", { style: {
|
|
115
|
+
padding: "16px 4px 0",
|
|
116
|
+
fontFamily: t.font,
|
|
117
|
+
fontSize: "12px",
|
|
118
|
+
lineHeight: "18px",
|
|
119
|
+
color: t.dimmed
|
|
120
|
+
} }, props.footer || (props.app === "wervt" ? "Sent by wervt." : `Sent by ${props.app} on wervt.`))])
|
|
121
|
+
]))])])])]);
|
|
122
|
+
}
|
|
123
|
+
});
|
|
124
|
+
const EmailHeading = defineComponent({
|
|
125
|
+
name: "EmailHeading",
|
|
126
|
+
setup(_, { slots }) {
|
|
127
|
+
return () => h("h1", { style: {
|
|
128
|
+
margin: "0 0 16px",
|
|
129
|
+
fontFamily: t.font,
|
|
130
|
+
fontSize: "20px",
|
|
131
|
+
lineHeight: "28px",
|
|
132
|
+
fontWeight: 600,
|
|
133
|
+
color: t.highlighted
|
|
134
|
+
} }, slots.default?.());
|
|
135
|
+
}
|
|
136
|
+
});
|
|
137
|
+
const EmailText = defineComponent({
|
|
138
|
+
name: "EmailText",
|
|
139
|
+
setup(_, { slots }) {
|
|
140
|
+
return () => h("p", { style: {
|
|
141
|
+
margin: "0 0 16px",
|
|
142
|
+
fontFamily: t.font,
|
|
143
|
+
fontSize: "15px",
|
|
144
|
+
lineHeight: "24px",
|
|
145
|
+
color: t.text
|
|
146
|
+
} }, slots.default?.());
|
|
147
|
+
}
|
|
148
|
+
});
|
|
149
|
+
/** Secondary text: hints, "ignore this if …". */
|
|
150
|
+
const EmailMuted = defineComponent({
|
|
151
|
+
name: "EmailMuted",
|
|
152
|
+
setup(_, { slots }) {
|
|
153
|
+
return () => h("p", { style: {
|
|
154
|
+
margin: "0 0 16px",
|
|
155
|
+
fontFamily: t.font,
|
|
156
|
+
fontSize: "13px",
|
|
157
|
+
lineHeight: "20px",
|
|
158
|
+
color: t.muted
|
|
159
|
+
} }, slots.default?.());
|
|
160
|
+
}
|
|
161
|
+
});
|
|
162
|
+
const EmailLink = defineComponent({
|
|
163
|
+
name: "EmailLink",
|
|
164
|
+
props: { href: {
|
|
165
|
+
type: String,
|
|
166
|
+
required: true
|
|
167
|
+
} },
|
|
168
|
+
setup(props, { slots }) {
|
|
169
|
+
return () => h("a", {
|
|
170
|
+
href: props.href,
|
|
171
|
+
style: {
|
|
172
|
+
color: t.highlighted,
|
|
173
|
+
textDecoration: "underline"
|
|
174
|
+
}
|
|
175
|
+
}, slots.default?.() ?? props.href);
|
|
176
|
+
}
|
|
177
|
+
});
|
|
178
|
+
/** A call to action. Bulletproof: a table cell carries the background, so Outlook shows it too. */
|
|
179
|
+
const EmailButton = defineComponent({
|
|
180
|
+
name: "EmailButton",
|
|
181
|
+
props: { href: {
|
|
182
|
+
type: String,
|
|
183
|
+
required: true
|
|
184
|
+
} },
|
|
185
|
+
setup(props, { slots }) {
|
|
186
|
+
return () => table({ style: { margin: "8px 0 24px" } }, [h("tr", [h("td", { style: {
|
|
187
|
+
background: t.button,
|
|
188
|
+
borderRadius: t.radius
|
|
189
|
+
} }, h("a", {
|
|
190
|
+
href: props.href,
|
|
191
|
+
style: {
|
|
192
|
+
display: "inline-block",
|
|
193
|
+
padding: "10px 18px",
|
|
194
|
+
fontFamily: t.font,
|
|
195
|
+
fontSize: "14px",
|
|
196
|
+
fontWeight: 500,
|
|
197
|
+
lineHeight: "20px",
|
|
198
|
+
color: t.buttonText,
|
|
199
|
+
textDecoration: "none",
|
|
200
|
+
borderRadius: t.radius
|
|
201
|
+
}
|
|
202
|
+
}, slots.default?.()))])]);
|
|
203
|
+
}
|
|
204
|
+
});
|
|
205
|
+
/** A one-time code, large and spaced so it reads (and copies) easily. */
|
|
206
|
+
const EmailCode = defineComponent({
|
|
207
|
+
name: "EmailCode",
|
|
208
|
+
props: { code: {
|
|
209
|
+
type: String,
|
|
210
|
+
required: true
|
|
211
|
+
} },
|
|
212
|
+
setup(props) {
|
|
213
|
+
return () => table({
|
|
214
|
+
width: "100%",
|
|
215
|
+
style: { margin: "8px 0 24px" }
|
|
216
|
+
}, [h("tr", [h("td", {
|
|
217
|
+
align: "center",
|
|
218
|
+
style: {
|
|
219
|
+
background: t.codeBackground,
|
|
220
|
+
borderRadius: t.radius,
|
|
221
|
+
padding: "16px",
|
|
222
|
+
fontFamily: t.mono,
|
|
223
|
+
fontSize: "30px",
|
|
224
|
+
fontWeight: 600,
|
|
225
|
+
letterSpacing: "8px",
|
|
226
|
+
color: t.highlighted
|
|
227
|
+
}
|
|
228
|
+
}, props.code)])]);
|
|
229
|
+
}
|
|
230
|
+
});
|
|
231
|
+
const EmailDivider = defineComponent({
|
|
232
|
+
name: "EmailDivider",
|
|
233
|
+
setup() {
|
|
234
|
+
return () => h("hr", { style: {
|
|
235
|
+
border: "none",
|
|
236
|
+
borderTop: `1px solid ${t.border}`,
|
|
237
|
+
margin: "24px 0"
|
|
238
|
+
} });
|
|
239
|
+
}
|
|
240
|
+
});
|
|
241
|
+
function table(attrs, children) {
|
|
242
|
+
return h("table", {
|
|
243
|
+
role: "presentation",
|
|
244
|
+
cellpadding: 0,
|
|
245
|
+
cellspacing: 0,
|
|
246
|
+
border: 0,
|
|
247
|
+
...attrs,
|
|
248
|
+
style: {
|
|
249
|
+
borderCollapse: "collapse",
|
|
250
|
+
...attrs.style
|
|
251
|
+
}
|
|
252
|
+
}, children);
|
|
253
|
+
}
|
|
254
|
+
//#endregion
|
|
255
|
+
export { EmailButton, EmailCode, EmailDivider, EmailHeading, EmailLink, EmailMuted, EmailText, SUBJECT_KEY, WervtEmail, emailTheme };
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
import { EmailButton, EmailCode, EmailDivider, EmailHeading, EmailLink, EmailMuted, EmailText, WervtEmail, emailTheme } from "./components.js";
|
|
2
|
+
import { EmailProps, RenderedEmail, renderEmail } from "./render.js";
|
|
3
|
+
export { EmailButton, EmailCode, EmailDivider, EmailHeading, EmailLink, EmailMuted, type EmailProps, EmailText, type RenderedEmail, WervtEmail, emailTheme, renderEmail };
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
import { EmailButton, EmailCode, EmailDivider, EmailHeading, EmailLink, EmailMuted, EmailText, WervtEmail, emailTheme } from "./components.js";
|
|
2
|
+
import { renderEmail } from "./render.js";
|
|
3
|
+
export { EmailButton, EmailCode, EmailDivider, EmailHeading, EmailLink, EmailMuted, EmailText, WervtEmail, emailTheme, renderEmail };
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { Component } from "vue";
|
|
2
|
+
//#region src/runtime/email/render.d.ts
|
|
3
|
+
export interface RenderedEmail {
|
|
4
|
+
subject: string;
|
|
5
|
+
html: string;
|
|
6
|
+
text: string;
|
|
7
|
+
}
|
|
8
|
+
/** The props a component declares, so templates are called with the right data. */
|
|
9
|
+
export type EmailProps<T> = T extends (new () => {
|
|
10
|
+
$props: infer P;
|
|
11
|
+
}) ? Omit<P, `on${string}`> : never;
|
|
12
|
+
/**
|
|
13
|
+
* Renders an email template (a component with `<WervtEmail>` at its root) to HTML, a plain-text
|
|
14
|
+
* part and its subject.
|
|
15
|
+
*/
|
|
16
|
+
export declare function renderEmail<T extends Component>(template: T, props?: EmailProps<T>): Promise<RenderedEmail>;
|
|
17
|
+
//#endregion
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { SUBJECT_KEY } from "./components.js";
|
|
2
|
+
import { createSSRApp } from "vue";
|
|
3
|
+
import { convert } from "html-to-text";
|
|
4
|
+
import { renderToString } from "vue/server-renderer";
|
|
5
|
+
//#region src/runtime/email/render.ts
|
|
6
|
+
const DOCTYPE = "<!DOCTYPE html PUBLIC \"-//W3C//DTD XHTML 1.0 Transitional//EN\" \"http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd\">";
|
|
7
|
+
/**
|
|
8
|
+
* Renders an email template (a component with `<WervtEmail>` at its root) to HTML, a plain-text
|
|
9
|
+
* part and its subject.
|
|
10
|
+
*/
|
|
11
|
+
async function renderEmail(template, props) {
|
|
12
|
+
let subject = "";
|
|
13
|
+
const app = createSSRApp(template, props);
|
|
14
|
+
app.provide(SUBJECT_KEY, (value) => subject = value);
|
|
15
|
+
const body = (await renderToString(app)).replace(/<!--[\s\S]*?-->/g, "");
|
|
16
|
+
if (!subject) throw new Error("Email templates need <WervtEmail subject=\"…\"> at their root");
|
|
17
|
+
const html = `${DOCTYPE}${body}`;
|
|
18
|
+
return {
|
|
19
|
+
subject,
|
|
20
|
+
html,
|
|
21
|
+
text: toText(html)
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
function toText(html) {
|
|
25
|
+
return convert(html, {
|
|
26
|
+
wordwrap: 78,
|
|
27
|
+
selectors: [
|
|
28
|
+
{
|
|
29
|
+
selector: ".wervt-preheader",
|
|
30
|
+
format: "skip"
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
selector: "title",
|
|
34
|
+
format: "skip"
|
|
35
|
+
},
|
|
36
|
+
{
|
|
37
|
+
selector: "img",
|
|
38
|
+
format: "skip"
|
|
39
|
+
},
|
|
40
|
+
{
|
|
41
|
+
selector: "h1",
|
|
42
|
+
options: { uppercase: false }
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
selector: "a",
|
|
46
|
+
options: { hideLinkHrefIfSameAsText: true }
|
|
47
|
+
},
|
|
48
|
+
{
|
|
49
|
+
selector: "table",
|
|
50
|
+
format: "block"
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
selector: "tr",
|
|
54
|
+
format: "block"
|
|
55
|
+
},
|
|
56
|
+
{
|
|
57
|
+
selector: "td",
|
|
58
|
+
format: "block"
|
|
59
|
+
}
|
|
60
|
+
]
|
|
61
|
+
}).trim();
|
|
62
|
+
}
|
|
63
|
+
//#endregion
|
|
64
|
+
export { renderEmail };
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { renderEmail } from "../email/render.js";
|
|
2
|
+
import { createError, defineEventHandler, getQuery } from "nuxt/server";
|
|
3
|
+
import { templates } from "#wervt/emails";
|
|
4
|
+
//#region src/runtime/server/email-preview.ts
|
|
5
|
+
const list = templates;
|
|
6
|
+
const page = (body) => new Response(`<!doctype html><meta charset="utf-8"><title>Emails</title><body style="font:15px/1.6 system-ui;margin:40px">${body}</body>`, { headers: { "content-type": "text/html; charset=utf-8" } });
|
|
7
|
+
const escape = (value) => value.replace(/[&<>"]/g, (c) => ({
|
|
8
|
+
"&": "&",
|
|
9
|
+
"<": "<",
|
|
10
|
+
">": ">",
|
|
11
|
+
"\"": """
|
|
12
|
+
})[c]);
|
|
13
|
+
var email_preview_default = defineEventHandler(async (event) => {
|
|
14
|
+
const name = decodeURIComponent(new URL(event.req.url).pathname.replace(/^\/_wervt\/emails\/?/, ""));
|
|
15
|
+
if (!name) {
|
|
16
|
+
const names = Object.keys(list);
|
|
17
|
+
return page(names.length ? `<h1>Email templates</h1><ul>${names.map((n) => `<li><a href="/_wervt/emails/${n}">${escape(n)}</a> · <a href="/_wervt/emails/${n}?text">text</a></li>`).join("")}</ul>` : "<h1>No email templates</h1><p>Add <code>emails/*.vue</code> and restart <code>nuxt dev</code>.</p>");
|
|
18
|
+
}
|
|
19
|
+
const load = list[name];
|
|
20
|
+
if (!load) throw createError({
|
|
21
|
+
status: 404,
|
|
22
|
+
message: `No email template "${name}"`
|
|
23
|
+
});
|
|
24
|
+
const template = await load();
|
|
25
|
+
const mail = await renderEmail(template.default, template.previewProps);
|
|
26
|
+
if ("text" in getQuery(event)) return new Response(`Subject: ${mail.subject}\n\n${mail.text}\n`, { headers: { "content-type": "text/plain; charset=utf-8" } });
|
|
27
|
+
return new Response(mail.html, { headers: { "content-type": "text/html; charset=utf-8" } });
|
|
28
|
+
});
|
|
29
|
+
//#endregion
|
|
30
|
+
export { email_preview_default as default };
|
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
import { WervtUser } from "../shared/user.js";
|
|
2
|
+
import { EmailProps, RenderedEmail, renderEmail } from "../email/render.js";
|
|
2
3
|
import { useDb } from "./db.js";
|
|
3
4
|
import { createEvlogError, log, useLogger } from "./logger.js";
|
|
5
|
+
import { SendMailOptions, sendMail } from "./mail.js";
|
|
4
6
|
import { McpTokenContext } from "./mcp-auth.js";
|
|
5
7
|
import { ShapeOptions, proxyShape } from "./shape.js";
|
|
6
8
|
import { Task, TaskEvent, TaskResult, defineTask } from "./tasks.js";
|
|
7
9
|
import { SafeResult, useSafeValidatedBody, useSafeValidatedParams, useSafeValidatedQuery, useValidatedBody, useValidatedParams, useValidatedQuery, vh } from "./validate.js";
|
|
8
10
|
import { Tool, defineTool } from "./tools.js";
|
|
9
11
|
import { useWervtUser } from "./user.js";
|
|
10
|
-
export { type McpTokenContext, type SafeResult, type ShapeOptions, type Task, type TaskEvent, type TaskResult, type Tool, type WervtUser, createEvlogError, defineTask, defineTool, log, proxyShape, useDb, useLogger, useSafeValidatedBody, useSafeValidatedParams, useSafeValidatedQuery, useValidatedBody, useValidatedParams, useValidatedQuery, useWervtUser, vh };
|
|
12
|
+
export { type EmailProps, type McpTokenContext, type RenderedEmail, type SafeResult, type SendMailOptions, type ShapeOptions, type Task, type TaskEvent, type TaskResult, type Tool, type WervtUser, createEvlogError, defineTask, defineTool, log, proxyShape, renderEmail, sendMail, useDb, useLogger, useSafeValidatedBody, useSafeValidatedParams, useSafeValidatedQuery, useValidatedBody, useValidatedParams, useValidatedQuery, useWervtUser, vh };
|
|
@@ -1,8 +1,10 @@
|
|
|
1
|
+
import { renderEmail } from "../email/render.js";
|
|
1
2
|
import { useDb } from "./db.js";
|
|
2
3
|
import { createEvlogError, log, useLogger } from "./logger.js";
|
|
4
|
+
import { sendMail } from "./mail.js";
|
|
3
5
|
import { proxyShape } from "./shape.js";
|
|
4
6
|
import { defineTask } from "./tasks.js";
|
|
5
7
|
import { defineTool } from "./tools.js";
|
|
6
8
|
import { useWervtUser } from "./user.js";
|
|
7
9
|
import { useSafeValidatedBody, useSafeValidatedParams, useSafeValidatedQuery, useValidatedBody, useValidatedParams, useValidatedQuery, vh } from "./validate.js";
|
|
8
|
-
export { createEvlogError, defineTask, defineTool, log, proxyShape, useDb, useLogger, useSafeValidatedBody, useSafeValidatedParams, useSafeValidatedQuery, useValidatedBody, useValidatedParams, useValidatedQuery, useWervtUser, vh };
|
|
10
|
+
export { createEvlogError, defineTask, defineTool, log, proxyShape, renderEmail, sendMail, useDb, useLogger, useSafeValidatedBody, useSafeValidatedParams, useSafeValidatedQuery, useValidatedBody, useValidatedParams, useValidatedQuery, useWervtUser, vh };
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { EmailProps } from "../email/render.js";
|
|
2
|
+
import { Component } from "vue";
|
|
3
|
+
//#region src/runtime/server/mail.d.ts
|
|
4
|
+
export interface SendMailOptions<T extends Component> {
|
|
5
|
+
/** One recipient. */
|
|
6
|
+
to: string;
|
|
7
|
+
/** A template from `emails/` (a component with `<WervtEmail>` at its root). */
|
|
8
|
+
template: T;
|
|
9
|
+
props?: EmailProps<T>;
|
|
10
|
+
/** Where replies go. The sender is always `<app> <mail@wervt.app>`. */
|
|
11
|
+
replyTo?: string;
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Renders a template and sends it through wervt. Throws on failure, with the status the platform
|
|
15
|
+
* answered: 400 (invalid mail), 429 (the app's or the recipient's hourly limit), 502/503.
|
|
16
|
+
*
|
|
17
|
+
* In `nuxt dev` nothing is sent: the mail is printed to the terminal, and `/_wervt/emails`
|
|
18
|
+
* previews the templates.
|
|
19
|
+
*/
|
|
20
|
+
export declare function sendMail<T extends Component>(options: SendMailOptions<T>): Promise<{
|
|
21
|
+
id: string;
|
|
22
|
+
}>;
|
|
23
|
+
//#endregion
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { renderEmail } from "../email/render.js";
|
|
2
|
+
import { createError } from "nuxt/server";
|
|
3
|
+
//#region src/runtime/server/mail.ts
|
|
4
|
+
/**
|
|
5
|
+
* Renders a template and sends it through wervt. Throws on failure, with the status the platform
|
|
6
|
+
* answered: 400 (invalid mail), 429 (the app's or the recipient's hourly limit), 502/503.
|
|
7
|
+
*
|
|
8
|
+
* In `nuxt dev` nothing is sent: the mail is printed to the terminal, and `/_wervt/emails`
|
|
9
|
+
* previews the templates.
|
|
10
|
+
*/
|
|
11
|
+
async function sendMail(options) {
|
|
12
|
+
const { subject, html, text } = await renderEmail(options.template, options.props);
|
|
13
|
+
const template = options.template.__name;
|
|
14
|
+
const runtime = process.env.WERVT_RUNTIME_URL;
|
|
15
|
+
if (!runtime) {
|
|
16
|
+
console.info(`[mail] to ${options.to}: ${subject}\n${text}`);
|
|
17
|
+
return { id: "dev" };
|
|
18
|
+
}
|
|
19
|
+
const res = await fetch(new URL("/_wervt/mail", runtime), {
|
|
20
|
+
method: "POST",
|
|
21
|
+
headers: {
|
|
22
|
+
"content-type": "application/json",
|
|
23
|
+
"x-wervt-app": process.env.WERVT_APP ?? "",
|
|
24
|
+
"x-wervt-token": process.env.WERVT_TOKEN ?? ""
|
|
25
|
+
},
|
|
26
|
+
body: JSON.stringify({
|
|
27
|
+
to: options.to,
|
|
28
|
+
subject,
|
|
29
|
+
html,
|
|
30
|
+
text,
|
|
31
|
+
replyTo: options.replyTo,
|
|
32
|
+
template
|
|
33
|
+
})
|
|
34
|
+
});
|
|
35
|
+
const body = await res.json().catch(() => ({}));
|
|
36
|
+
if (!res.ok) throw createError({
|
|
37
|
+
status: res.status,
|
|
38
|
+
message: body.error ?? `sending mail failed (${res.status})`
|
|
39
|
+
});
|
|
40
|
+
return { id: body.id ?? "" };
|
|
41
|
+
}
|
|
42
|
+
//#endregion
|
|
43
|
+
export { sendMail };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@wervt/nuxt",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.15",
|
|
4
4
|
"description": "Nuxt module for apps hosted on wervt: database, live data, MCP and runtime setup.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"drizzle",
|
|
@@ -33,6 +33,10 @@
|
|
|
33
33
|
"types": "./dist/runtime/server/index.d.ts",
|
|
34
34
|
"default": "./dist/runtime/server/index.js"
|
|
35
35
|
},
|
|
36
|
+
"./email": {
|
|
37
|
+
"types": "./dist/runtime/email/index.d.ts",
|
|
38
|
+
"default": "./dist/runtime/email/index.js"
|
|
39
|
+
},
|
|
36
40
|
"./provision": {
|
|
37
41
|
"types": "./dist/provision.d.ts",
|
|
38
42
|
"default": "./dist/provision.js"
|
|
@@ -44,11 +48,13 @@
|
|
|
44
48
|
"aws4fetch": "^1.0.20",
|
|
45
49
|
"evlog": "^2.30.1",
|
|
46
50
|
"h3-mcp": "0.2.0",
|
|
51
|
+
"html-to-text": "^9.0.5",
|
|
47
52
|
"jose": "^6.2.12",
|
|
48
53
|
"postgres": "^3.4.9",
|
|
49
54
|
"valibot": "^1.5.0"
|
|
50
55
|
},
|
|
51
56
|
"devDependencies": {
|
|
57
|
+
"@types/html-to-text": "^9.0.4",
|
|
52
58
|
"drizzle-orm": "1.0.0-rc.4",
|
|
53
59
|
"h3": "2.0.2",
|
|
54
60
|
"nitro": "3.0.260903-beta",
|