@constructive-io/send-email-link-fn 0.3.1

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/LICENSE ADDED
@@ -0,0 +1,23 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2025 Dan Lynch <pyramation@gmail.com>
4
+ Copyright (c) 2025 Constructive <developers@constructive.io>
5
+ Copyright (c) 2020-present, Interweb, Inc.
6
+
7
+ Permission is hereby granted, free of charge, to any person obtaining a copy
8
+ of this software and associated documentation files (the "Software"), to deal
9
+ in the Software without restriction, including without limitation the rights
10
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
11
+ copies of the Software, and to permit persons to whom the Software is
12
+ furnished to do so, subject to the following conditions:
13
+
14
+ The above copyright notice and this permission notice shall be included in all
15
+ copies or substantial portions of the Software.
16
+
17
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
18
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
19
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
20
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
21
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
22
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
23
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,236 @@
1
+ # @constructive-io/send-email-link-fn
2
+
3
+ Knative-compatible email link function used with the Constructive jobs system. It is designed to be invoked by `@constructive-io/knative-job-worker` as an HTTP function named `send-email-link`.
4
+
5
+ The function:
6
+ - Reads metadata about the tenant/site from a GraphQL API
7
+ - Generates a styled HTML email using `@launchql/mjml`
8
+ - Sends the email via `@launchql/postmaster`
9
+ - Supports invite, password reset, and email verification flows
10
+
11
+ ## Expected job payload
12
+
13
+ Jobs should use `task_identifier = 'send-email-link'` and a JSON payload like:
14
+
15
+ ```json
16
+ {
17
+ "email_type": "invite_email",
18
+ "email": "user@example.com",
19
+ "invite_token": "abc123",
20
+ "sender_id": "00000000-0000-0000-0000-000000000001"
21
+ }
22
+ ```
23
+
24
+ Supported `email_type` values and parameters:
25
+
26
+ - `invite_email`
27
+ - `email` (string, required)
28
+ - `invite_token` (string, required)
29
+ - `sender_id` (UUID string, required)
30
+ - `forgot_password`
31
+ - `email` (string, required)
32
+ - `user_id` (UUID string, required)
33
+ - `reset_token` (string, required)
34
+ - `email_verification`
35
+ - `email` (string, required)
36
+ - `email_id` (UUID string, required)
37
+ - `verification_token` (string, required)
38
+
39
+ If required fields are missing the function returns a small JSON object like:
40
+
41
+ ```json
42
+ { "missing": "email_type" }
43
+ ```
44
+
45
+ ## HTTP contract (with knative-job-worker)
46
+
47
+ The function is wrapped by `@constructive-io/knative-job-fn`, so it expects:
48
+
49
+ - HTTP method: `POST`
50
+ - Body: JSON job payload (see above)
51
+ - Headers (set by `@constructive-io/knative-job-worker`):
52
+ - `X-Worker-Id`
53
+ - `X-Job-Id`
54
+ - `X-Database-Id`
55
+ - `X-Callback-Url`
56
+
57
+ The handler will:
58
+
59
+ 1. Resolve the tenant/site by `databaseId` via GraphQL
60
+ 2. Generate an email link and HTML via `@launchql/mjml`
61
+ 3. Send the email with `@launchql/postmaster`
62
+ 4. Respond with HTTP 200 and JSON:
63
+
64
+ ```json
65
+ { "complete": true }
66
+ ```
67
+
68
+ Errors are propagated through the Express error middleware installed by `@constructive-io/knative-job-fn`, so they can be translated into `X-Job-Error` callbacks by your gateway/callback server.
69
+
70
+ ## Environment variables
71
+
72
+ Required:
73
+
74
+ - `GRAPHQL_URL`
75
+ GraphQL endpoint for the tenant database (for `GetUser` and/or per-tenant data).
76
+
77
+ Recommended / optional:
78
+
79
+ - `META_GRAPHQL_URL`
80
+ GraphQL endpoint for meta/database-level schema. Defaults to `GRAPHQL_URL` when not set.
81
+ - `GRAPHQL_AUTH_TOKEN`
82
+ Bearer token to send as `Authorization` header for GraphQL requests.
83
+ - `DEFAULT_DATABASE_ID`
84
+ Used if `X-Database-Id` is not provided by the worker. In normal jobs usage, `X-Database-Id` should always be present.
85
+ - `LOCAL_APP_PORT`
86
+ Optional port suffix for localhost-style hosts (e.g. `3000`). When the resolved hostname is `localhost` / `*.localhost` and `SEND_EMAIL_LINK_DRY_RUN=true`, links are generated as `http://localhost:LOCAL_APP_PORT/...`. Ignored for non-local hostnames and in production.
87
+
88
+ Email delivery (default: `@launchql/postmaster`):
89
+
90
+ - Set `EMAIL_SEND_USE_SMTP=true` to switch to `simple-smtp-server` (SMTP). Otherwise it uses `@launchql/postmaster`.
91
+
92
+ - Mailgun or another provider; consult `@launchql/postmaster` docs. A common pattern is:
93
+ - `MAILGUN_API_KEY`
94
+ - `MAILGUN_DOMAIN`
95
+ - `MAILGUN_FROM`
96
+
97
+ - SMTP variables when `EMAIL_SEND_USE_SMTP=true`:
98
+ - `SMTP_HOST`
99
+ - `SMTP_PORT`
100
+ - `SMTP_USER`
101
+ - `SMTP_PASS`
102
+ - `SMTP_FROM`
103
+
104
+ ## Building locally
105
+
106
+ From the repo root:
107
+
108
+ ```bash
109
+ pnpm --filter="@constructive-io/send-email-link-fn" build
110
+ ```
111
+
112
+ This compiles TypeScript into `dist/`.
113
+
114
+ ## Dockerfile
115
+
116
+ The function is intended to be containerized and run as a Knative Service. A minimal Dockerfile:
117
+
118
+ ```dockerfile
119
+ FROM node:18-alpine
120
+
121
+ WORKDIR /usr/src/app
122
+
123
+ # Install production dependencies
124
+ COPY package.json pnpm-lock.yaml ./
125
+ RUN npm install -g pnpm@9 && pnpm install --prod
126
+
127
+ # Copy compiled code
128
+ COPY dist ./dist
129
+
130
+ ENV NODE_ENV=production
131
+ ENV PORT=8080
132
+
133
+ CMD ["node", "dist/index.js"]
134
+ ```
135
+
136
+ Build and push:
137
+
138
+ ```bash
139
+ pnpm --filter="@constructive-io/send-email-link-fn" build
140
+ docker build -t your-registry/send-email-link-fn:latest functions/send-email-link
141
+ docker push your-registry/send-email-link-fn:latest
142
+ ```
143
+
144
+ ## Example Knative Service
145
+
146
+ ```yaml
147
+ apiVersion: serving.knative.dev/v1
148
+ kind: Service
149
+ metadata:
150
+ name: send-email-link
151
+ namespace: default
152
+ spec:
153
+ template:
154
+ spec:
155
+ containers:
156
+ - image: your-registry/send-email-link-fn:latest
157
+ env:
158
+ - name: GRAPHQL_URL
159
+ value: "https://api.your-domain.com/graphql"
160
+ - name: META_GRAPHQL_URL
161
+ value: "https://meta-api.your-domain.com/graphql"
162
+ - name: GRAPHQL_AUTH_TOKEN
163
+ valueFrom:
164
+ secretKeyRef:
165
+ name: graphql-auth
166
+ key: token
167
+ # MAILGUN / Postmaster config here...
168
+ - name: MAILGUN_API_KEY
169
+ valueFrom:
170
+ secretKeyRef:
171
+ name: mailgun
172
+ key: api-key
173
+ ```
174
+
175
+ Once deployed, point `@constructive-io/knative-job-worker` at this service by configuring:
176
+
177
+ - `KNATIVE_SERVICE_URL` to route `/send-email-link` to this function
178
+ - `JOBS_SUPPORTED=send-email-link` (or `JOBS_SUPPORT_ANY=true`)
179
+
180
+ ---
181
+
182
+ ## Education and Tutorials
183
+
184
+ 1. 🚀 [Quickstart: Getting Up and Running](https://constructive.io/learn/quickstart)
185
+ Get started with modular databases in minutes. Install prerequisites and deploy your first module.
186
+
187
+ 2. 📦 [Modular PostgreSQL Development with Database Packages](https://constructive.io/learn/modular-postgres)
188
+ Learn to organize PostgreSQL projects with pgpm workspaces and reusable database modules.
189
+
190
+ 3. ✏️ [Authoring Database Changes](https://constructive.io/learn/authoring-database-changes)
191
+ Master the workflow for adding, organizing, and managing database changes with pgpm.
192
+
193
+ 4. 🧪 [End-to-End PostgreSQL Testing with TypeScript](https://constructive.io/learn/e2e-postgres-testing)
194
+ Master end-to-end PostgreSQL testing with ephemeral databases, RLS testing, and CI/CD automation.
195
+
196
+ 5. ⚡ [Supabase Testing](https://constructive.io/learn/supabase)
197
+ Use TypeScript-first tools to test Supabase projects with realistic RLS, policies, and auth contexts.
198
+
199
+ 6. 💧 [Drizzle ORM Testing](https://constructive.io/learn/drizzle-testing)
200
+ Run full-stack tests with Drizzle ORM, including database setup, teardown, and RLS enforcement.
201
+
202
+ 7. 🔧 [Troubleshooting](https://constructive.io/learn/troubleshooting)
203
+ Common issues and solutions for pgpm, PostgreSQL, and testing.
204
+
205
+ ## Related Constructive Tooling
206
+
207
+ ### 📦 Package Management
208
+
209
+ * [pgpm](https://github.com/constructive-io/constructive/tree/main/pgpm/pgpm): **🖥️ PostgreSQL Package Manager** for modular Postgres development. Works with database workspaces, scaffolding, migrations, seeding, and installing database packages.
210
+
211
+ ### 🧪 Testing
212
+
213
+ * [pgsql-test](https://github.com/constructive-io/constructive/tree/main/postgres/pgsql-test): **📊 Isolated testing environments** with per-test transaction rollbacks—ideal for integration tests, complex migrations, and RLS simulation.
214
+ * [pgsql-seed](https://github.com/constructive-io/constructive/tree/main/postgres/pgsql-seed): **🌱 PostgreSQL seeding utilities** for CSV, JSON, SQL data loading, and pgpm deployment.
215
+ * [supabase-test](https://github.com/constructive-io/constructive/tree/main/postgres/supabase-test): **🧪 Supabase-native test harness** preconfigured for the local Supabase stack—per-test rollbacks, JWT/role context helpers, and CI/GitHub Actions ready.
216
+ * [graphile-test](https://github.com/constructive-io/constructive/tree/main/graphile/graphile-test): **🔐 Authentication mocking** for Graphile-focused test helpers and emulating row-level security contexts.
217
+ * [pg-query-context](https://github.com/constructive-io/constructive/tree/main/postgres/pg-query-context): **🔒 Session context injection** to add session-local context (e.g., `SET LOCAL`) into queries—ideal for setting `role`, `jwt.claims`, and other session settings.
218
+
219
+ ### 🧠 Parsing & AST
220
+
221
+ * [pgsql-parser](https://www.npmjs.com/package/pgsql-parser): **🔄 SQL conversion engine** that interprets and converts PostgreSQL syntax.
222
+ * [libpg-query-node](https://www.npmjs.com/package/libpg-query): **🌉 Node.js bindings** for `libpg_query`, converting SQL into parse trees.
223
+ * [pg-proto-parser](https://www.npmjs.com/package/pg-proto-parser): **📦 Protobuf parser** for parsing PostgreSQL Protocol Buffers definitions to generate TypeScript interfaces, utility functions, and JSON mappings for enums.
224
+ * [@pgsql/enums](https://www.npmjs.com/package/@pgsql/enums): **🏷️ TypeScript enums** for PostgreSQL AST for safe and ergonomic parsing logic.
225
+ * [@pgsql/types](https://www.npmjs.com/package/@pgsql/types): **📝 Type definitions** for PostgreSQL AST nodes in TypeScript.
226
+ * [@pgsql/utils](https://www.npmjs.com/package/@pgsql/utils): **🛠️ AST utilities** for constructing and transforming PostgreSQL syntax trees.
227
+
228
+ ## Credits
229
+
230
+ **🛠 Built by the [Constructive](https://constructive.io) team — creators of modular Postgres tooling for secure, composable backends. If you like our work, contribute on [GitHub](https://github.com/constructive-io).**
231
+
232
+ ## Disclaimer
233
+
234
+ AS DESCRIBED IN THE LICENSES, THE SOFTWARE IS PROVIDED "AS IS", AT YOUR OWN RISK, AND WITHOUT WARRANTIES OF ANY KIND.
235
+
236
+ No developer or entity involved in creating this software will be liable for any claims or damages whatsoever associated with your use, inability to use, or your interaction with other users of the code, including any direct, indirect, incidental, special, exemplary, punitive or consequential damages, or loss of profits, cryptocurrencies, tokens, or anything else of value.
package/esm/index.js ADDED
@@ -0,0 +1,277 @@
1
+ import { createJobApp } from '@constructive-io/knative-job-fn';
2
+ import { GraphQLClient } from 'graphql-request';
3
+ import gql from 'graphql-tag';
4
+ import { generate } from '@launchql/mjml';
5
+ import { send as sendPostmaster } from '@launchql/postmaster';
6
+ import { send as sendSmtp } from 'simple-smtp-server';
7
+ import { parseEnvBoolean } from '@pgpmjs/env';
8
+ import { createLogger } from '@pgpmjs/logger';
9
+ const isDryRun = parseEnvBoolean(process.env.SEND_EMAIL_LINK_DRY_RUN) ?? false;
10
+ const useSmtp = parseEnvBoolean(process.env.EMAIL_SEND_USE_SMTP) ?? false;
11
+ const logger = createLogger('send-email-link');
12
+ const app = createJobApp();
13
+ const GetUser = gql `
14
+ query GetUser($userId: UUID!) {
15
+ user(id: $userId) {
16
+ username
17
+ displayName
18
+ profilePicture
19
+ }
20
+ }
21
+ `;
22
+ const GetDatabaseInfo = gql `
23
+ query GetDatabaseInfo($databaseId: UUID!) {
24
+ database(id: $databaseId) {
25
+ sites {
26
+ nodes {
27
+ domains {
28
+ nodes {
29
+ subdomain
30
+ domain
31
+ }
32
+ }
33
+ logo
34
+ title
35
+ siteThemes {
36
+ nodes {
37
+ theme
38
+ }
39
+ }
40
+ siteModules(condition: { name: "legal_terms_module" }) {
41
+ nodes {
42
+ data
43
+ }
44
+ }
45
+ }
46
+ }
47
+ }
48
+ }
49
+ `;
50
+ const getRequiredEnv = (name) => {
51
+ const value = process.env[name];
52
+ if (!value) {
53
+ throw new Error(`Missing required environment variable ${name}`);
54
+ }
55
+ return value;
56
+ };
57
+ const createGraphQLClient = (url, hostHeaderEnvVar) => {
58
+ const headers = {};
59
+ if (process.env.GRAPHQL_AUTH_TOKEN) {
60
+ headers.Authorization = `Bearer ${process.env.GRAPHQL_AUTH_TOKEN}`;
61
+ }
62
+ const envName = hostHeaderEnvVar || 'GRAPHQL_HOST_HEADER';
63
+ const hostHeader = process.env[envName];
64
+ if (hostHeader) {
65
+ headers.host = hostHeader;
66
+ }
67
+ return new GraphQLClient(url, { headers });
68
+ };
69
+ export const sendEmailLink = async (params, context) => {
70
+ const { client, meta, databaseId } = context;
71
+ const validateForType = () => {
72
+ switch (params.email_type) {
73
+ case 'invite_email':
74
+ if (!params.invite_token || !params.sender_id) {
75
+ return { missing: 'invite_token_or_sender_id' };
76
+ }
77
+ return null;
78
+ case 'forgot_password':
79
+ if (!params.user_id || !params.reset_token) {
80
+ return { missing: 'user_id_or_reset_token' };
81
+ }
82
+ return null;
83
+ case 'email_verification':
84
+ if (!params.email_id || !params.verification_token) {
85
+ return { missing: 'email_id_or_verification_token' };
86
+ }
87
+ return null;
88
+ default:
89
+ return { missing: 'email_type' };
90
+ }
91
+ };
92
+ if (!params.email_type) {
93
+ return { missing: 'email_type' };
94
+ }
95
+ if (!params.email) {
96
+ return { missing: 'email' };
97
+ }
98
+ const typeValidation = validateForType();
99
+ if (typeValidation) {
100
+ return typeValidation;
101
+ }
102
+ const databaseInfo = await meta.request(GetDatabaseInfo, {
103
+ databaseId
104
+ });
105
+ const site = databaseInfo?.database?.sites?.nodes?.[0];
106
+ if (!site) {
107
+ throw new Error('Site not found for database');
108
+ }
109
+ const legalTermsModule = site.siteModules?.nodes?.[0];
110
+ const domainNode = site.domains?.nodes?.[0];
111
+ const theme = site.siteThemes?.nodes?.[0]?.theme;
112
+ if (!legalTermsModule || !domainNode || !theme) {
113
+ throw new Error('Missing site configuration for email');
114
+ }
115
+ const subdomain = domainNode.subdomain;
116
+ const domain = domainNode.domain;
117
+ const supportEmail = legalTermsModule.data.emails.support;
118
+ const logo = site.logo?.url;
119
+ const company = legalTermsModule.data.company;
120
+ const website = company.website;
121
+ const nick = company.nick;
122
+ const name = company.name;
123
+ const primary = theme.primary;
124
+ const hostname = subdomain ? [subdomain, domain].join('.') : domain;
125
+ // Treat localhost-style hosts specially so we can generate
126
+ // http://localhost[:port]/... links for local dev without
127
+ // breaking production URLs.
128
+ const isLocalHost = hostname.startsWith('localhost') ||
129
+ hostname.startsWith('0.0.0.0') ||
130
+ hostname.endsWith('.localhost');
131
+ // Optional: LOCAL_APP_PORT lets you attach a port for local dashboards
132
+ // e.g. LOCAL_APP_PORT=3000 -> http://localhost:3000
133
+ // It is ignored for non-local hostnames. Only allow on DRY RUNs
134
+ const localPort = isLocalHost && isDryRun && process.env.LOCAL_APP_PORT
135
+ ? `:${process.env.LOCAL_APP_PORT}`
136
+ : '';
137
+ // Use http only for local dry-run to avoid browser TLS warnings
138
+ // in dev; production stays https.
139
+ const protocol = isLocalHost && isDryRun ? 'http' : 'https';
140
+ const url = new URL(`${protocol}://${hostname}${localPort}`);
141
+ let subject;
142
+ let subMessage;
143
+ let linkText;
144
+ let inviterName;
145
+ switch (params.email_type) {
146
+ case 'invite_email': {
147
+ if (!params.invite_token || !params.sender_id) {
148
+ return { missing: 'invite_token_or_sender_id' };
149
+ }
150
+ url.pathname = 'register';
151
+ url.searchParams.append('invite_token', params.invite_token);
152
+ url.searchParams.append('email', params.email);
153
+ const scope = Number(params.invite_type) === 2 ? 'org' : 'app';
154
+ url.searchParams.append('type', scope);
155
+ const inviter = await client.request(GetUser, {
156
+ userId: params.sender_id
157
+ });
158
+ inviterName = inviter?.user?.displayName;
159
+ if (inviterName) {
160
+ subject = `${inviterName} invited you to ${nick}!`;
161
+ subMessage = `You've been invited to ${nick}`;
162
+ }
163
+ else {
164
+ subject = `Welcome to ${nick}!`;
165
+ subMessage = `You've been invited to ${nick}`;
166
+ }
167
+ linkText = 'Join Us';
168
+ break;
169
+ }
170
+ case 'forgot_password': {
171
+ if (!params.user_id || !params.reset_token) {
172
+ return { missing: 'user_id_or_reset_token' };
173
+ }
174
+ url.pathname = 'reset-password';
175
+ url.searchParams.append('role_id', params.user_id);
176
+ url.searchParams.append('reset_token', params.reset_token);
177
+ subject = `${nick} Password Reset Request`;
178
+ subMessage = 'Click below to reset your password';
179
+ linkText = 'Reset Password';
180
+ break;
181
+ }
182
+ case 'email_verification': {
183
+ if (!params.email_id || !params.verification_token) {
184
+ return { missing: 'email_id_or_verification_token' };
185
+ }
186
+ url.pathname = 'verify-email';
187
+ url.searchParams.append('email_id', params.email_id);
188
+ url.searchParams.append('verification_token', params.verification_token);
189
+ subject = `${nick} Email Verification`;
190
+ subMessage = 'Please confirm your email address';
191
+ linkText = 'Confirm Email';
192
+ break;
193
+ }
194
+ default:
195
+ return false;
196
+ }
197
+ const link = url.href;
198
+ const html = generate({
199
+ title: subject,
200
+ link,
201
+ linkText,
202
+ message: subject,
203
+ subMessage,
204
+ bodyBgColor: 'white',
205
+ headerBgColor: 'white',
206
+ messageBgColor: 'white',
207
+ messageTextColor: '#414141',
208
+ messageButtonBgColor: primary,
209
+ messageButtonTextColor: 'white',
210
+ companyName: name,
211
+ supportEmail,
212
+ website,
213
+ logo,
214
+ headerImageProps: {
215
+ alt: 'logo',
216
+ align: 'center',
217
+ border: 'none',
218
+ width: '162px',
219
+ paddingLeft: '0px',
220
+ paddingRight: '0px',
221
+ paddingBottom: '0px',
222
+ paddingTop: '0'
223
+ }
224
+ });
225
+ if (isDryRun) {
226
+ logger.info('DRY RUN email (skipping send)', {
227
+ email_type: params.email_type,
228
+ email: params.email,
229
+ subject,
230
+ link
231
+ });
232
+ }
233
+ else {
234
+ const sendEmail = useSmtp ? sendSmtp : sendPostmaster;
235
+ await sendEmail({
236
+ to: params.email,
237
+ subject,
238
+ html
239
+ });
240
+ }
241
+ return {
242
+ complete: true,
243
+ ...(isDryRun ? { dryRun: true } : null)
244
+ };
245
+ };
246
+ // HTTP/Knative entrypoint (used by @constructive-io/knative-job-fn wrapper)
247
+ app.post('/', async (req, res, next) => {
248
+ try {
249
+ const params = (req.body || {});
250
+ const databaseId = req.get('X-Database-Id') || req.get('x-database-id') || process.env.DEFAULT_DATABASE_ID;
251
+ if (!databaseId) {
252
+ return res.status(400).json({ error: 'Missing X-Database-Id header or DEFAULT_DATABASE_ID' });
253
+ }
254
+ const graphqlUrl = getRequiredEnv('GRAPHQL_URL');
255
+ const metaGraphqlUrl = process.env.META_GRAPHQL_URL || graphqlUrl;
256
+ const client = createGraphQLClient(graphqlUrl, 'GRAPHQL_HOST_HEADER');
257
+ const meta = createGraphQLClient(metaGraphqlUrl, 'META_GRAPHQL_HOST_HEADER');
258
+ const result = await sendEmailLink(params, {
259
+ client,
260
+ meta,
261
+ databaseId
262
+ });
263
+ res.status(200).json(result);
264
+ }
265
+ catch (err) {
266
+ next(err);
267
+ }
268
+ });
269
+ export default app;
270
+ // When executed directly (e.g. via `node dist/index.js`), start an HTTP server.
271
+ if (require.main === module) {
272
+ const port = Number(process.env.PORT ?? 8080);
273
+ // @constructive-io/knative-job-fn exposes a .listen method that delegates to the Express app
274
+ app.listen(port, () => {
275
+ logger.info(`listening on port ${port}`);
276
+ });
277
+ }
package/index.d.ts ADDED
@@ -0,0 +1,25 @@
1
+ import { GraphQLClient } from 'graphql-request';
2
+ declare const app: any;
3
+ type SendEmailParams = {
4
+ email_type: 'invite_email' | 'forgot_password' | 'email_verification';
5
+ email: string;
6
+ invite_type?: number | string;
7
+ invite_token?: string;
8
+ sender_id?: string;
9
+ user_id?: string;
10
+ reset_token?: string;
11
+ email_id?: string;
12
+ verification_token?: string;
13
+ };
14
+ type GraphQLContext = {
15
+ client: GraphQLClient;
16
+ meta: GraphQLClient;
17
+ databaseId: string;
18
+ };
19
+ export declare const sendEmailLink: (params: SendEmailParams, context: GraphQLContext) => Promise<false | {
20
+ missing?: string;
21
+ } | {
22
+ dryRun: boolean;
23
+ complete: boolean;
24
+ }>;
25
+ export default app;
package/index.js ADDED
@@ -0,0 +1,284 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.sendEmailLink = void 0;
7
+ const knative_job_fn_1 = require("@constructive-io/knative-job-fn");
8
+ const graphql_request_1 = require("graphql-request");
9
+ const graphql_tag_1 = __importDefault(require("graphql-tag"));
10
+ const mjml_1 = require("@launchql/mjml");
11
+ const postmaster_1 = require("@launchql/postmaster");
12
+ const simple_smtp_server_1 = require("simple-smtp-server");
13
+ const env_1 = require("@pgpmjs/env");
14
+ const logger_1 = require("@pgpmjs/logger");
15
+ const isDryRun = (0, env_1.parseEnvBoolean)(process.env.SEND_EMAIL_LINK_DRY_RUN) ?? false;
16
+ const useSmtp = (0, env_1.parseEnvBoolean)(process.env.EMAIL_SEND_USE_SMTP) ?? false;
17
+ const logger = (0, logger_1.createLogger)('send-email-link');
18
+ const app = (0, knative_job_fn_1.createJobApp)();
19
+ const GetUser = (0, graphql_tag_1.default) `
20
+ query GetUser($userId: UUID!) {
21
+ user(id: $userId) {
22
+ username
23
+ displayName
24
+ profilePicture
25
+ }
26
+ }
27
+ `;
28
+ const GetDatabaseInfo = (0, graphql_tag_1.default) `
29
+ query GetDatabaseInfo($databaseId: UUID!) {
30
+ database(id: $databaseId) {
31
+ sites {
32
+ nodes {
33
+ domains {
34
+ nodes {
35
+ subdomain
36
+ domain
37
+ }
38
+ }
39
+ logo
40
+ title
41
+ siteThemes {
42
+ nodes {
43
+ theme
44
+ }
45
+ }
46
+ siteModules(condition: { name: "legal_terms_module" }) {
47
+ nodes {
48
+ data
49
+ }
50
+ }
51
+ }
52
+ }
53
+ }
54
+ }
55
+ `;
56
+ const getRequiredEnv = (name) => {
57
+ const value = process.env[name];
58
+ if (!value) {
59
+ throw new Error(`Missing required environment variable ${name}`);
60
+ }
61
+ return value;
62
+ };
63
+ const createGraphQLClient = (url, hostHeaderEnvVar) => {
64
+ const headers = {};
65
+ if (process.env.GRAPHQL_AUTH_TOKEN) {
66
+ headers.Authorization = `Bearer ${process.env.GRAPHQL_AUTH_TOKEN}`;
67
+ }
68
+ const envName = hostHeaderEnvVar || 'GRAPHQL_HOST_HEADER';
69
+ const hostHeader = process.env[envName];
70
+ if (hostHeader) {
71
+ headers.host = hostHeader;
72
+ }
73
+ return new graphql_request_1.GraphQLClient(url, { headers });
74
+ };
75
+ const sendEmailLink = async (params, context) => {
76
+ const { client, meta, databaseId } = context;
77
+ const validateForType = () => {
78
+ switch (params.email_type) {
79
+ case 'invite_email':
80
+ if (!params.invite_token || !params.sender_id) {
81
+ return { missing: 'invite_token_or_sender_id' };
82
+ }
83
+ return null;
84
+ case 'forgot_password':
85
+ if (!params.user_id || !params.reset_token) {
86
+ return { missing: 'user_id_or_reset_token' };
87
+ }
88
+ return null;
89
+ case 'email_verification':
90
+ if (!params.email_id || !params.verification_token) {
91
+ return { missing: 'email_id_or_verification_token' };
92
+ }
93
+ return null;
94
+ default:
95
+ return { missing: 'email_type' };
96
+ }
97
+ };
98
+ if (!params.email_type) {
99
+ return { missing: 'email_type' };
100
+ }
101
+ if (!params.email) {
102
+ return { missing: 'email' };
103
+ }
104
+ const typeValidation = validateForType();
105
+ if (typeValidation) {
106
+ return typeValidation;
107
+ }
108
+ const databaseInfo = await meta.request(GetDatabaseInfo, {
109
+ databaseId
110
+ });
111
+ const site = databaseInfo?.database?.sites?.nodes?.[0];
112
+ if (!site) {
113
+ throw new Error('Site not found for database');
114
+ }
115
+ const legalTermsModule = site.siteModules?.nodes?.[0];
116
+ const domainNode = site.domains?.nodes?.[0];
117
+ const theme = site.siteThemes?.nodes?.[0]?.theme;
118
+ if (!legalTermsModule || !domainNode || !theme) {
119
+ throw new Error('Missing site configuration for email');
120
+ }
121
+ const subdomain = domainNode.subdomain;
122
+ const domain = domainNode.domain;
123
+ const supportEmail = legalTermsModule.data.emails.support;
124
+ const logo = site.logo?.url;
125
+ const company = legalTermsModule.data.company;
126
+ const website = company.website;
127
+ const nick = company.nick;
128
+ const name = company.name;
129
+ const primary = theme.primary;
130
+ const hostname = subdomain ? [subdomain, domain].join('.') : domain;
131
+ // Treat localhost-style hosts specially so we can generate
132
+ // http://localhost[:port]/... links for local dev without
133
+ // breaking production URLs.
134
+ const isLocalHost = hostname.startsWith('localhost') ||
135
+ hostname.startsWith('0.0.0.0') ||
136
+ hostname.endsWith('.localhost');
137
+ // Optional: LOCAL_APP_PORT lets you attach a port for local dashboards
138
+ // e.g. LOCAL_APP_PORT=3000 -> http://localhost:3000
139
+ // It is ignored for non-local hostnames. Only allow on DRY RUNs
140
+ const localPort = isLocalHost && isDryRun && process.env.LOCAL_APP_PORT
141
+ ? `:${process.env.LOCAL_APP_PORT}`
142
+ : '';
143
+ // Use http only for local dry-run to avoid browser TLS warnings
144
+ // in dev; production stays https.
145
+ const protocol = isLocalHost && isDryRun ? 'http' : 'https';
146
+ const url = new URL(`${protocol}://${hostname}${localPort}`);
147
+ let subject;
148
+ let subMessage;
149
+ let linkText;
150
+ let inviterName;
151
+ switch (params.email_type) {
152
+ case 'invite_email': {
153
+ if (!params.invite_token || !params.sender_id) {
154
+ return { missing: 'invite_token_or_sender_id' };
155
+ }
156
+ url.pathname = 'register';
157
+ url.searchParams.append('invite_token', params.invite_token);
158
+ url.searchParams.append('email', params.email);
159
+ const scope = Number(params.invite_type) === 2 ? 'org' : 'app';
160
+ url.searchParams.append('type', scope);
161
+ const inviter = await client.request(GetUser, {
162
+ userId: params.sender_id
163
+ });
164
+ inviterName = inviter?.user?.displayName;
165
+ if (inviterName) {
166
+ subject = `${inviterName} invited you to ${nick}!`;
167
+ subMessage = `You've been invited to ${nick}`;
168
+ }
169
+ else {
170
+ subject = `Welcome to ${nick}!`;
171
+ subMessage = `You've been invited to ${nick}`;
172
+ }
173
+ linkText = 'Join Us';
174
+ break;
175
+ }
176
+ case 'forgot_password': {
177
+ if (!params.user_id || !params.reset_token) {
178
+ return { missing: 'user_id_or_reset_token' };
179
+ }
180
+ url.pathname = 'reset-password';
181
+ url.searchParams.append('role_id', params.user_id);
182
+ url.searchParams.append('reset_token', params.reset_token);
183
+ subject = `${nick} Password Reset Request`;
184
+ subMessage = 'Click below to reset your password';
185
+ linkText = 'Reset Password';
186
+ break;
187
+ }
188
+ case 'email_verification': {
189
+ if (!params.email_id || !params.verification_token) {
190
+ return { missing: 'email_id_or_verification_token' };
191
+ }
192
+ url.pathname = 'verify-email';
193
+ url.searchParams.append('email_id', params.email_id);
194
+ url.searchParams.append('verification_token', params.verification_token);
195
+ subject = `${nick} Email Verification`;
196
+ subMessage = 'Please confirm your email address';
197
+ linkText = 'Confirm Email';
198
+ break;
199
+ }
200
+ default:
201
+ return false;
202
+ }
203
+ const link = url.href;
204
+ const html = (0, mjml_1.generate)({
205
+ title: subject,
206
+ link,
207
+ linkText,
208
+ message: subject,
209
+ subMessage,
210
+ bodyBgColor: 'white',
211
+ headerBgColor: 'white',
212
+ messageBgColor: 'white',
213
+ messageTextColor: '#414141',
214
+ messageButtonBgColor: primary,
215
+ messageButtonTextColor: 'white',
216
+ companyName: name,
217
+ supportEmail,
218
+ website,
219
+ logo,
220
+ headerImageProps: {
221
+ alt: 'logo',
222
+ align: 'center',
223
+ border: 'none',
224
+ width: '162px',
225
+ paddingLeft: '0px',
226
+ paddingRight: '0px',
227
+ paddingBottom: '0px',
228
+ paddingTop: '0'
229
+ }
230
+ });
231
+ if (isDryRun) {
232
+ logger.info('DRY RUN email (skipping send)', {
233
+ email_type: params.email_type,
234
+ email: params.email,
235
+ subject,
236
+ link
237
+ });
238
+ }
239
+ else {
240
+ const sendEmail = useSmtp ? simple_smtp_server_1.send : postmaster_1.send;
241
+ await sendEmail({
242
+ to: params.email,
243
+ subject,
244
+ html
245
+ });
246
+ }
247
+ return {
248
+ complete: true,
249
+ ...(isDryRun ? { dryRun: true } : null)
250
+ };
251
+ };
252
+ exports.sendEmailLink = sendEmailLink;
253
+ // HTTP/Knative entrypoint (used by @constructive-io/knative-job-fn wrapper)
254
+ app.post('/', async (req, res, next) => {
255
+ try {
256
+ const params = (req.body || {});
257
+ const databaseId = req.get('X-Database-Id') || req.get('x-database-id') || process.env.DEFAULT_DATABASE_ID;
258
+ if (!databaseId) {
259
+ return res.status(400).json({ error: 'Missing X-Database-Id header or DEFAULT_DATABASE_ID' });
260
+ }
261
+ const graphqlUrl = getRequiredEnv('GRAPHQL_URL');
262
+ const metaGraphqlUrl = process.env.META_GRAPHQL_URL || graphqlUrl;
263
+ const client = createGraphQLClient(graphqlUrl, 'GRAPHQL_HOST_HEADER');
264
+ const meta = createGraphQLClient(metaGraphqlUrl, 'META_GRAPHQL_HOST_HEADER');
265
+ const result = await (0, exports.sendEmailLink)(params, {
266
+ client,
267
+ meta,
268
+ databaseId
269
+ });
270
+ res.status(200).json(result);
271
+ }
272
+ catch (err) {
273
+ next(err);
274
+ }
275
+ });
276
+ exports.default = app;
277
+ // When executed directly (e.g. via `node dist/index.js`), start an HTTP server.
278
+ if (require.main === module) {
279
+ const port = Number(process.env.PORT ?? 8080);
280
+ // @constructive-io/knative-job-fn exposes a .listen method that delegates to the Express app
281
+ app.listen(port, () => {
282
+ logger.info(`listening on port ${port}`);
283
+ });
284
+ }
package/package.json ADDED
@@ -0,0 +1,47 @@
1
+ {
2
+ "name": "@constructive-io/send-email-link-fn",
3
+ "version": "0.3.1",
4
+ "description": "Knative function to send email links (invite, password reset, email verification) using Constructive jobs",
5
+ "author": "Constructive <developers@constructive.io>",
6
+ "homepage": "https://github.com/constructive-io/constructive",
7
+ "license": "MIT",
8
+ "main": "index.js",
9
+ "module": "esm/index.js",
10
+ "types": "index.d.ts",
11
+ "publishConfig": {
12
+ "access": "public",
13
+ "directory": "dist"
14
+ },
15
+ "repository": {
16
+ "type": "git",
17
+ "url": "https://github.com/constructive-io/constructive"
18
+ },
19
+ "bugs": {
20
+ "url": "https://github.com/constructive-io/constructive/issues"
21
+ },
22
+ "directories": {
23
+ "lib": "src",
24
+ "test": "__tests__"
25
+ },
26
+ "scripts": {
27
+ "clean": "makage clean",
28
+ "prepack": "npm run build",
29
+ "build": "makage build",
30
+ "build:dev": "makage build --dev"
31
+ },
32
+ "devDependencies": {
33
+ "makage": "^0.1.10"
34
+ },
35
+ "dependencies": {
36
+ "@constructive-io/knative-job-fn": "^0.3.1",
37
+ "@launchql/mjml": "0.1.1",
38
+ "@launchql/postmaster": "0.1.4",
39
+ "@launchql/styled-email": "0.1.0",
40
+ "@pgpmjs/env": "^2.10.0",
41
+ "@pgpmjs/logger": "^1.4.0",
42
+ "graphql-request": "^7.1.2",
43
+ "graphql-tag": "^2.12.6",
44
+ "simple-smtp-server": "^0.2.0"
45
+ },
46
+ "gitHead": "3ffd5718e86ea5fa9ca6e0930aeb510cf392f343"
47
+ }