diff --git a/docker-compose.jobs.yml b/docker-compose.jobs.yml index 907f08a9c..d900f0ba3 100644 --- a/docker-compose.jobs.yml +++ b/docker-compose.jobs.yml @@ -80,6 +80,10 @@ services: MAILGUN_DOMAIN: "mg.constructive.io" MAILGUN_FROM: "no-reply@mg.constructive.io" MAILGUN_REPLY: "info@mg.constructive.io" + # Local dashboard port for generated links, used only for + # localhost-style hosts in DRY RUN mode: + # http://localhost:LOCAL_APP_PORT/... + LOCAL_APP_PORT: "3000" SEND_EMAIL_LINK_DRY_RUN: "${SEND_EMAIL_LINK_DRY_RUN:-true}" ports: # Expose function locally (optional) diff --git a/functions/send-email-link/README.md b/functions/send-email-link/README.md index 2ad727da4..13f7d9e8f 100644 --- a/functions/send-email-link/README.md +++ b/functions/send-email-link/README.md @@ -82,6 +82,8 @@ Recommended / optional: Bearer token to send as `Authorization` header for GraphQL requests. - `DEFAULT_DATABASE_ID` Used if `X-Database-Id` is not provided by the worker. In normal jobs usage, `X-Database-Id` should always be present. +- `LOCAL_APP_PORT` + 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. Email delivery (used by `@launchql/postmaster`): diff --git a/functions/send-email-link/src/index.ts b/functions/send-email-link/src/index.ts index 2b09a6545..47c35c1ec 100644 --- a/functions/send-email-link/src/index.ts +++ b/functions/send-email-link/src/index.ts @@ -157,8 +157,28 @@ export const sendEmailLink = async ( const name = company.name; const primary = theme.primary; - const baseUrl = 'https://' + (subdomain ? [subdomain, domain].join('.') : domain); - const url = new URL(baseUrl); + const hostname = subdomain ? [subdomain, domain].join('.') : domain; + + // Treat localhost-style hosts specially so we can generate + // http://localhost[:port]/... links for local dev without + // breaking production URLs. + const isLocalHost = + hostname.startsWith('localhost') || + hostname.startsWith('0.0.0.0') || + hostname.endsWith('.localhost'); + + // Optional: LOCAL_APP_PORT lets you attach a port for local dashboards + // e.g. LOCAL_APP_PORT=3000 -> http://localhost:3000 + // It is ignored for non-local hostnames. Only allow on DRY RUNs + const localPort = + isLocalHost && isDryRun && process.env.LOCAL_APP_PORT + ? `:${process.env.LOCAL_APP_PORT}` + : ''; + + // Use http only for local dry-run to avoid browser TLS warnings + // in dev; production stays https. + const protocol = isLocalHost && isDryRun ? 'http' : 'https'; + const url = new URL(`${protocol}://${hostname}${localPort}`); let subject: string; let subMessage: string; diff --git a/jobs/DEVELOPMENT_JOBS.md b/jobs/DEVELOPMENT_JOBS.md index 308e489f2..068b7e4ab 100644 --- a/jobs/DEVELOPMENT_JOBS.md +++ b/jobs/DEVELOPMENT_JOBS.md @@ -229,6 +229,10 @@ You should then see the job picked up by `knative-job-service` and the email pay - The app/meta packages deployed in step 3 (`app-svc-local`, `db-meta`) - A real `database_id` (use `$DBID` above) - A GraphQL hostname that matches a seeded domain route (step 5) +- For localhost development, the site/domain metadata usually resolves to `localhost`. + In that case, the function will honor the `LOCAL_APP_PORT` env (default `3000` in + `docker-compose.jobs.yml`) and generate links like `http://localhost:3000/...` + when `SEND_EMAIL_LINK_DRY_RUN=true`. With `SEND_EMAIL_LINK_DRY_RUN=true` (default in `docker-compose.jobs.yml`), enqueue a job: diff --git a/jobs/knative-job-fn/src/index.ts b/jobs/knative-job-fn/src/index.ts index c6da061d9..77befd2b1 100644 --- a/jobs/knative-job-fn/src/index.ts +++ b/jobs/knative-job-fn/src/index.ts @@ -13,6 +13,15 @@ type JobContext = { databaseId: string | undefined; }; +function getHeaders(req: any) { + return { + 'x-worker-id': req.get('X-Worker-Id'), + 'x-job-id': req.get('X-Job-Id'), + 'x-database-id': req.get('X-Database-Id'), + 'x-callback-url': req.get('X-Callback-Url') + }; +} + const app: any = express(); app.use(bodyParser.json()); @@ -21,12 +30,7 @@ app.use(bodyParser.json()); app.use((req: any, res: any, next: any) => { try { // Log only the headers we care about plus a shallow body snapshot - const headers = { - 'x-worker-id': req.get('X-Worker-Id'), - 'x-job-id': req.get('X-Job-Id'), - 'x-database-id': req.get('X-Database-Id'), - 'x-callback-url': req.get('X-Callback-Url') - }; + const headers = getHeaders(req); let body: any; if (req.body && typeof req.body === 'object') { @@ -229,12 +233,7 @@ export default { // Log the full error context for debugging. try { - const headers = { - 'x-worker-id': req.get('X-Worker-Id'), - 'x-job-id': req.get('X-Job-Id'), - 'x-database-id': req.get('X-Database-Id'), - 'x-callback-url': req.get('X-Callback-Url') - }; + const headers = getHeaders(req); // Some error types (e.g. GraphQL ClientError) expose response info. const errorDetails: any = {