Skip to content

Commit 8a46ad3

Browse files
authored
Merge pull request #412 from constructive-io/refactor/knative-job-fn
Refactor/knative job send email link fn
2 parents 0b06be5 + 5c6b80b commit 8a46ad3

11 files changed

Lines changed: 252 additions & 61 deletions

File tree

DEVELOPMENT_JOBS.md

Lines changed: 130 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,10 @@
22

33
This guide covers a local development workflow for the jobs stack:
44

5-
- Postgres + `launchql-ext-jobs`
5+
- Postgres + `launchql-database-jobs`
66
- LaunchQL API server
77
- `simple-email` function
8+
- `send-email-link` function
89
- `knative-job-service`
910

1011
It assumes:
@@ -102,19 +103,111 @@ This starts:
102103

103104
- `launchql-server` – GraphQL API server
104105
- `simple-email` – Knative-style HTTP function
106+
- `send-email-link` – Knative-style HTTP function
105107
- `knative-job-service` – jobs runtime (callback server + worker + scheduler)
106108

107109
---
108110

109-
## 5. Enqueue a test job (simple-email)
111+
### Switching dry run vs real Mailgun sending
112+
113+
By default, `docker-compose.jobs.yml` runs both email functions in dry-run mode (no real email is sent), and it uses placeholder Mailgun credentials unless you provide `MAILGUN_API_KEY` / `MAILGUN_KEY`.
114+
115+
Quick start commands:
116+
117+
Dry run:
118+
119+
```sh
120+
docker compose -f docker-compose.jobs.yml up -d --build --force-recreate
121+
```
122+
123+
Real sending (Mailgun):
124+
125+
```sh
126+
MAILGUN_API_KEY="your-mailgun-key" MAILGUN_KEY="your-mailgun-key" SIMPLE_EMAIL_DRY_RUN=false SEND_EMAIL_LINK_DRY_RUN=false docker compose -f docker-compose.jobs.yml up -d --build --force-recreate
127+
```
128+
129+
To use a real Mailgun key without editing `docker-compose.jobs.yml`, set these env vars before starting the stack (or put them in a local `.env` file in the `constructive/` directory). Don't commit your `.env`.
130+
131+
```sh
132+
export MAILGUN_API_KEY="your-mailgun-key"
133+
export MAILGUN_KEY="your-mailgun-key"
134+
```
135+
136+
If you're not using `mg.constructive.io`, also override `MAILGUN_DOMAIN`, `MAILGUN_FROM`, and `MAILGUN_REPLY` (for example in the override file below) to match your Mailgun setup.
137+
138+
To actually send email (instead of dry-run), set these env vars (or put them in your local `.env`):
139+
140+
```sh
141+
export SIMPLE_EMAIL_DRY_RUN=false
142+
export SEND_EMAIL_LINK_DRY_RUN=false
143+
```
144+
145+
Then recreate the stack so the new env is applied:
146+
147+
```sh
148+
docker compose -f docker-compose.jobs.yml up -d --build --force-recreate
149+
```
150+
151+
If you prefer not to export env vars, create a local override file (don't commit it) at `docker-compose.jobs.override.yml`:
152+
153+
```yml
154+
services:
155+
simple-email:
156+
environment:
157+
SIMPLE_EMAIL_DRY_RUN: "false"
158+
159+
send-email-link:
160+
environment:
161+
SEND_EMAIL_LINK_DRY_RUN: "false"
162+
```
163+
164+
Start the stack with both files:
165+
166+
```sh
167+
docker compose -f docker-compose.jobs.yml -f docker-compose.jobs.override.yml up -d --build --force-recreate
168+
```
169+
170+
To switch back to dry-run, set `SIMPLE_EMAIL_DRY_RUN=true` and `SEND_EMAIL_LINK_DRY_RUN=true` (or delete the override file) and recreate again.
171+
172+
---
173+
174+
## 5. Ensure GraphQL host routing works for `send-email-link`
175+
176+
LaunchQL selects the API by the HTTP `Host` header using rows in `meta_public.domains`.
177+
178+
For local development, `app-svc-local` seeds `admin.localhost` as the admin API domain. `docker-compose.jobs.yml` adds a Docker network alias so other containers can resolve `admin.localhost` to the `launchql-server` container, and `send-email-link` uses:
179+
180+
- `GRAPHQL_URL=http://admin.localhost:3000/graphql`
181+
182+
Quick check from your host (should return JSON, not HTML):
183+
184+
```sh
185+
curl -s -H 'Host: admin.localhost' \
186+
-H 'Content-Type: application/json' \
187+
-X POST http://localhost:3000/graphql \
188+
--data '{"query":"query { __typename }"}'
189+
```
190+
191+
If your GraphQL server requires auth, set `GRAPHQL_AUTH_TOKEN` before starting the jobs stack (it is passed through to the `send-email-link` container).
192+
193+
---
194+
195+
## 6. Enqueue a test job (simple-email)
110196

111197
With the jobs stack running, you can enqueue a test job from your host into the Postgres container:
112198

199+
First, grab a real `database_id` (required by `send-email-link`, optional for `simple-email`):
200+
201+
```sh
202+
DBID="$(docker exec -i postgres psql -U postgres -d launchql -Atc 'SELECT id FROM collections_public.database ORDER BY created_at LIMIT 1;')"
203+
echo "$DBID"
204+
```
205+
113206
```sh
114207
docker exec -it postgres \
115208
psql -U postgres -d launchql -c "
116209
SELECT app_jobs.add_job(
117-
'00000000-0000-0000-0000-000000000001'::uuid,
210+
'$DBID'::uuid,
118211
'simple-email',
119212
json_build_object(
120213
'to', 'user@example.com',
@@ -129,7 +222,39 @@ You should then see the job picked up by `knative-job-service` and the email pay
129222

130223
---
131224

132-
## 6. Inspect logs and iterate
225+
## 7. Enqueue a test job (`send-email-link`)
226+
227+
`send-email-link` queries GraphQL for site/database metadata, so it requires:
228+
229+
- The app/meta packages deployed in step 3 (`app-svc-local`, `db-meta`)
230+
- A real `database_id` (use `$DBID` above)
231+
- A GraphQL hostname that matches a seeded domain route (step 5)
232+
233+
With `SEND_EMAIL_LINK_DRY_RUN=true` (default in `docker-compose.jobs.yml`), enqueue a job:
234+
235+
```sh
236+
docker exec -it postgres \
237+
psql -U postgres -d launchql -c "
238+
SELECT app_jobs.add_job(
239+
'$DBID'::uuid,
240+
'send-email-link',
241+
json_build_object(
242+
'email_type', 'invite_email',
243+
'email', 'user@example.com',
244+
'invite_token', 'invite123',
245+
'sender_id', '00000000-0000-0000-0000-000000000000'
246+
)::json
247+
);
248+
"
249+
```
250+
251+
You should see a log like:
252+
253+
- `[send-email-link] DRY RUN email (skipping send) ...`
254+
255+
---
256+
257+
## 8. Inspect logs and iterate
133258

134259
To watch logs while you develop:
135260

@@ -153,7 +278,7 @@ docker compose -f docker-compose.jobs.yml up --build
153278

154279
---
155280

156-
## 7. Stopping services
281+
## 9. Stopping services
157282

158283
To stop only the jobs stack:
159284

docker-compose.jobs.yml

Lines changed: 38 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,10 @@ services:
3232
ports:
3333
- "3000:3000"
3434
networks:
35-
- constructive-net
35+
constructive-net:
36+
aliases:
37+
# Let other containers call the admin API using the seeded domain route.
38+
- admin.localhost
3639

3740
# Simple email function (Knative-style HTTP function)
3841
simple-email:
@@ -43,11 +46,11 @@ services:
4346
environment:
4447
NODE_ENV: development
4548
LOG_LEVEL: info
46-
SIMPLE_EMAIL_DRY_RUN: "true"
49+
SIMPLE_EMAIL_DRY_RUN: "${SIMPLE_EMAIL_DRY_RUN:-true}"
4750
# Mailgun / email provider configuration for @launchql/postmaster
4851
# Replace with real credentials for local testing.
49-
MAILGUN_API_KEY: "change-me-mailgun-api-key"
50-
MAILGUN_KEY: "change-me-mailgun-api-key"
52+
MAILGUN_API_KEY: "${MAILGUN_API_KEY:-change-me-mailgun-api-key}"
53+
MAILGUN_KEY: "${MAILGUN_KEY:-change-me-mailgun-api-key}"
5154
MAILGUN_DOMAIN: "mg.constructive.io"
5255
MAILGUN_FROM: "no-reply@mg.constructive.io"
5356
MAILGUN_REPLY: "info@mg.constructive.io"
@@ -57,6 +60,33 @@ services:
5760
networks:
5861
- constructive-net
5962

63+
# Send email link function (invite, password reset, verification)
64+
send-email-link:
65+
container_name: send-email-link
66+
image: constructive-launchql:dev
67+
entrypoint: ["node", "functions/send-email-link/dist/index.js"]
68+
environment:
69+
NODE_ENV: development
70+
LOG_LEVEL: info
71+
DEFAULT_DATABASE_ID: "dbe"
72+
# LaunchQL selects the API by Host header; use a seeded domain route.
73+
GRAPHQL_URL: "http://admin.localhost:3000/graphql"
74+
META_GRAPHQL_URL: "http://admin.localhost:3000/graphql"
75+
# Optional: provide an existing API token (Bearer) if your server requires it.
76+
GRAPHQL_AUTH_TOKEN: "${GRAPHQL_AUTH_TOKEN:-}"
77+
# Mailgun / email provider configuration for @launchql/postmaster
78+
MAILGUN_API_KEY: "${MAILGUN_API_KEY:-change-me-mailgun-api-key}"
79+
MAILGUN_KEY: "${MAILGUN_KEY:-change-me-mailgun-api-key}"
80+
MAILGUN_DOMAIN: "mg.constructive.io"
81+
MAILGUN_FROM: "no-reply@mg.constructive.io"
82+
MAILGUN_REPLY: "info@mg.constructive.io"
83+
SEND_EMAIL_LINK_DRY_RUN: "${SEND_EMAIL_LINK_DRY_RUN:-true}"
84+
ports:
85+
# Expose function locally (optional)
86+
- "8082:8080"
87+
networks:
88+
- constructive-net
89+
6090
# Jobs runtime: callback server + worker + scheduler
6191
knative-job-service:
6292
container_name: knative-job-service
@@ -65,6 +95,7 @@ services:
6595
entrypoint: ["node", "jobs/knative-job-service/dist/run.js"]
6696
depends_on:
6797
- simple-email
98+
- send-email-link
6899
environment:
69100
NODE_ENV: development
70101

@@ -78,7 +109,7 @@ services:
78109

79110
# Worker configuration
80111
JOBS_SUPPORT_ANY: "false"
81-
JOBS_SUPPORTED: "simple-email"
112+
JOBS_SUPPORTED: "simple-email,send-email-link"
82113
HOSTNAME: "knative-job-service-1"
83114

84115
# Callback HTTP server (job completion callbacks)
@@ -92,8 +123,8 @@ services:
92123

93124
# Development-only map from task identifier -> function URL
94125
# Used by @launchql/knative-job-worker when NODE_ENV !== 'production'.
95-
# This lets the worker call the simple-email container directly in docker-compose.
96-
INTERNAL_GATEWAY_DEVELOPMENT_MAP: '{"simple-email":"http://simple-email:8080"}'
126+
# This lets the worker call the function containers directly in docker-compose.
127+
INTERNAL_GATEWAY_DEVELOPMENT_MAP: '{"simple-email":"http://simple-email:8080","send-email-link":"http://send-email-link:8080"}'
97128

98129
ports:
99130
- "8080:8080"

functions/send-email-link/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
"@launchql/mjml": "0.1.1",
1919
"@launchql/postmaster": "0.1.4",
2020
"@launchql/styled-email": "0.1.0",
21+
"@pgpmjs/env": "workspace:^",
2122
"graphql-request": "^7.1.2",
2223
"graphql-tag": "^2.12.6"
2324
}

functions/send-email-link/src/index.ts

Lines changed: 56 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,9 @@ import { GraphQLClient } from 'graphql-request';
33
import gql from 'graphql-tag';
44
import { generate } from '@launchql/mjml';
55
import { send } from '@launchql/postmaster';
6+
import { parseEnvBoolean } from '@pgpmjs/env';
7+
8+
const isDryRun = parseEnvBoolean(process.env.SEND_EMAIL_LINK_DRY_RUN) ?? false;
69

710
const GetUser = gql`
811
query GetUser($userId: UUID!) {
@@ -84,13 +87,40 @@ export const sendEmailLink = async (
8487
) => {
8588
const { client, meta, databaseId } = context;
8689

90+
const validateForType = (): { missing?: string } | null => {
91+
switch (params.email_type) {
92+
case 'invite_email':
93+
if (!params.invite_token || !params.sender_id) {
94+
return { missing: 'invite_token_or_sender_id' };
95+
}
96+
return null;
97+
case 'forgot_password':
98+
if (!params.user_id || !params.reset_token) {
99+
return { missing: 'user_id_or_reset_token' };
100+
}
101+
return null;
102+
case 'email_verification':
103+
if (!params.email_id || !params.verification_token) {
104+
return { missing: 'email_id_or_verification_token' };
105+
}
106+
return null;
107+
default:
108+
return { missing: 'email_type' };
109+
}
110+
};
111+
87112
if (!params.email_type) {
88113
return { missing: 'email_type' };
89114
}
90115
if (!params.email) {
91116
return { missing: 'email' };
92117
}
93118

119+
const typeValidation = validateForType();
120+
if (typeValidation) {
121+
return typeValidation;
122+
}
123+
94124
const databaseInfo = await meta.request<any>(GetDatabaseInfo, {
95125
databaseId
96126
});
@@ -209,14 +239,25 @@ export const sendEmailLink = async (
209239
}
210240
});
211241

212-
await send({
213-
to: params.email,
214-
subject,
215-
html
216-
});
242+
if (isDryRun) {
243+
// eslint-disable-next-line no-console
244+
console.log('[send-email-link] DRY RUN email (skipping send)', {
245+
email_type: params.email_type,
246+
email: params.email,
247+
subject,
248+
link
249+
});
250+
} else {
251+
await send({
252+
to: params.email,
253+
subject,
254+
html
255+
});
256+
}
217257

218258
return {
219-
complete: true
259+
complete: true,
260+
...(isDryRun ? { dryRun: true } : null)
220261
};
221262
};
222263

@@ -251,3 +292,12 @@ app.post('*', async (req: any, res: any, next: any) => {
251292

252293
export default app;
253294

295+
// When executed directly (e.g. via `node dist/index.js`), start an HTTP server.
296+
if (require.main === module) {
297+
const port = Number(process.env.PORT ?? 8080);
298+
// @launchql/knative-job-fn exposes a .listen method that delegates to the Express app
299+
(app as any).listen(port, () => {
300+
// eslint-disable-next-line no-console
301+
console.log(`[send-email-link] listening on port ${port}`);
302+
});
303+
}

functions/simple-email/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
},
1616
"dependencies": {
1717
"@launchql/knative-job-fn": "workspace:^",
18-
"@launchql/postmaster": "0.1.4"
18+
"@launchql/postmaster": "0.1.4",
19+
"@pgpmjs/env": "workspace:^"
1920
}
2021
}

functions/simple-email/src/index.ts

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import app from '@launchql/knative-job-fn';
2+
import { parseEnvBoolean } from '@pgpmjs/env';
23
import { send as sendEmail } from '@launchql/postmaster';
34

45
type SimpleEmailPayload = {
@@ -24,12 +25,7 @@ const getRequiredField = (
2425
return value;
2526
};
2627

27-
const isDryRun = (() => {
28-
const val = process.env.SIMPLE_EMAIL_DRY_RUN;
29-
if (!val) return false;
30-
const s = val.toLowerCase();
31-
return s === 'true' || s === '1' || s === 'yes' || s === 'y';
32-
})();
28+
const isDryRun = parseEnvBoolean(process.env.SIMPLE_EMAIL_DRY_RUN) ?? false;
3329

3430
app.post('*', async (req: any, res: any, next: any) => {
3531
try {

0 commit comments

Comments
 (0)