create-forgeon 0.3.24 → 0.3.25
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/package.json +1 -1
- package/src/modules/communications.mjs +4 -3
- package/src/modules/dependencies.test.mjs +6 -9
- package/src/modules/executor.test.mjs +90 -2
- package/src/modules/registry.mjs +6 -6
- package/templates/module-fragments/communications/20_scope.md +5 -2
- package/templates/module-presets/accounts/packages/accounts-api/src/forgeon-accounts.module.ts +1 -3
- package/templates/module-presets/communications/packages/communications/src/communications-config.loader.ts +2 -1
- package/templates/module-presets/communications/packages/communications/src/communications-env.schema.ts +21 -5
- package/templates/module-presets/communications/packages/communications/src/email/providers/gmail-smtp-email.provider.ts +52 -12
- package/templates/module-presets/communications/packages/communications/src/forgeon-communications.module.ts +2 -1
package/package.json
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import fs from 'node:fs';
|
|
1
|
+
import fs from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { copyRecursive, writeJson } from '../utils/fs.mjs';
|
|
4
4
|
import {
|
|
@@ -165,6 +165,7 @@ function patchReadme(targetRoot) {
|
|
|
165
165
|
'Example env keys:',
|
|
166
166
|
'- `COMMUNICATIONS_EMAIL_PROVIDER=gmail-smtp`',
|
|
167
167
|
'- `COMMUNICATIONS_EMAIL_SMTP_HOST=smtp.gmail.com`',
|
|
168
|
+
'- `COMMUNICATIONS_EMAIL_FROM=` (falls back to the SMTP user when left empty)',
|
|
168
169
|
'- `COMMUNICATIONS_EMAIL_SMTP_USER=`',
|
|
169
170
|
'- `COMMUNICATIONS_EMAIL_SMTP_PASS=`',
|
|
170
171
|
].join('\n');
|
|
@@ -203,7 +204,7 @@ export function applyCommunicationsModule({ packageRoot, targetRoot }) {
|
|
|
203
204
|
upsertEnvLines(path.join(targetRoot, 'apps', 'api', '.env.example'), [
|
|
204
205
|
'COMMUNICATIONS_TEMPLATES_ROOT=resources/communications',
|
|
205
206
|
'COMMUNICATIONS_EMAIL_PROVIDER=gmail-smtp',
|
|
206
|
-
'COMMUNICATIONS_EMAIL_FROM=
|
|
207
|
+
'COMMUNICATIONS_EMAIL_FROM=',
|
|
207
208
|
'COMMUNICATIONS_EMAIL_REPLY_TO=',
|
|
208
209
|
'COMMUNICATIONS_EMAIL_SUBJECT_PREFIX=[Forgeon]',
|
|
209
210
|
'COMMUNICATIONS_EMAIL_SMTP_HOST=smtp.gmail.com',
|
|
@@ -218,7 +219,7 @@ export function applyCommunicationsModule({ packageRoot, targetRoot }) {
|
|
|
218
219
|
upsertEnvLines(path.join(targetRoot, 'infra', 'docker', '.env.example'), [
|
|
219
220
|
'COMMUNICATIONS_TEMPLATES_ROOT=resources/communications',
|
|
220
221
|
'COMMUNICATIONS_EMAIL_PROVIDER=gmail-smtp',
|
|
221
|
-
'COMMUNICATIONS_EMAIL_FROM=
|
|
222
|
+
'COMMUNICATIONS_EMAIL_FROM=',
|
|
222
223
|
'COMMUNICATIONS_EMAIL_REPLY_TO=',
|
|
223
224
|
'COMMUNICATIONS_EMAIL_SUBJECT_PREFIX=[Forgeon]',
|
|
224
225
|
'COMMUNICATIONS_EMAIL_SMTP_HOST=smtp.gmail.com',
|
|
@@ -119,7 +119,7 @@ const TEST_PRESETS = [
|
|
|
119
119
|
implemented: true,
|
|
120
120
|
detectionPaths: ['packages/files-access/package.json'],
|
|
121
121
|
provides: ['files-access-runtime'],
|
|
122
|
-
requires: [{ type: '
|
|
122
|
+
requires: [{ type: 'module', id: 'files' }],
|
|
123
123
|
optionalIntegrations: [],
|
|
124
124
|
},
|
|
125
125
|
{
|
|
@@ -128,7 +128,7 @@ const TEST_PRESETS = [
|
|
|
128
128
|
implemented: true,
|
|
129
129
|
detectionPaths: ['packages/files-quotas/package.json'],
|
|
130
130
|
provides: ['files-quotas-runtime'],
|
|
131
|
-
requires: [{ type: '
|
|
131
|
+
requires: [{ type: 'module', id: 'files' }],
|
|
132
132
|
optionalIntegrations: [],
|
|
133
133
|
},
|
|
134
134
|
{
|
|
@@ -137,7 +137,7 @@ const TEST_PRESETS = [
|
|
|
137
137
|
implemented: true,
|
|
138
138
|
detectionPaths: ['packages/files-image/package.json'],
|
|
139
139
|
provides: ['files-image-runtime'],
|
|
140
|
-
requires: [{ type: '
|
|
140
|
+
requires: [{ type: 'module', id: 'files' }],
|
|
141
141
|
optionalIntegrations: [],
|
|
142
142
|
},
|
|
143
143
|
{
|
|
@@ -269,7 +269,7 @@ describe('module dependency helpers', () => {
|
|
|
269
269
|
}
|
|
270
270
|
});
|
|
271
271
|
|
|
272
|
-
it('resolves files-access plan through files
|
|
272
|
+
it('resolves files-access plan through files core module dependency', async () => {
|
|
273
273
|
const targetRoot = mkTmp('forgeon-deps-files-access-plan-');
|
|
274
274
|
|
|
275
275
|
try {
|
|
@@ -289,14 +289,13 @@ describe('module dependency helpers', () => {
|
|
|
289
289
|
assert.deepEqual(result.selectedProviders, {
|
|
290
290
|
'db-adapter': 'db-prisma',
|
|
291
291
|
'files-storage-adapter': 'files-local',
|
|
292
|
-
'files-runtime': 'files',
|
|
293
292
|
});
|
|
294
293
|
} finally {
|
|
295
294
|
fs.rmSync(targetRoot, { recursive: true, force: true });
|
|
296
295
|
}
|
|
297
296
|
});
|
|
298
297
|
|
|
299
|
-
it('resolves files-quotas plan through files
|
|
298
|
+
it('resolves files-quotas plan through files core module dependency', async () => {
|
|
300
299
|
const targetRoot = mkTmp('forgeon-deps-files-quotas-plan-');
|
|
301
300
|
|
|
302
301
|
try {
|
|
@@ -316,14 +315,13 @@ describe('module dependency helpers', () => {
|
|
|
316
315
|
assert.deepEqual(result.selectedProviders, {
|
|
317
316
|
'db-adapter': 'db-prisma',
|
|
318
317
|
'files-storage-adapter': 'files-local',
|
|
319
|
-
'files-runtime': 'files',
|
|
320
318
|
});
|
|
321
319
|
} finally {
|
|
322
320
|
fs.rmSync(targetRoot, { recursive: true, force: true });
|
|
323
321
|
}
|
|
324
322
|
});
|
|
325
323
|
|
|
326
|
-
it('resolves files-image plan through files
|
|
324
|
+
it('resolves files-image plan through files core module dependency', async () => {
|
|
327
325
|
const targetRoot = mkTmp('forgeon-deps-files-image-plan-');
|
|
328
326
|
|
|
329
327
|
try {
|
|
@@ -343,7 +341,6 @@ describe('module dependency helpers', () => {
|
|
|
343
341
|
assert.deepEqual(result.selectedProviders, {
|
|
344
342
|
'db-adapter': 'db-prisma',
|
|
345
343
|
'files-storage-adapter': 'files-local',
|
|
346
|
-
'files-runtime': 'files',
|
|
347
344
|
});
|
|
348
345
|
} finally {
|
|
349
346
|
fs.rmSync(targetRoot, { recursive: true, force: true });
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { describe, it } from 'node:test';
|
|
1
|
+
import { describe, it } from 'node:test';
|
|
2
2
|
import assert from 'node:assert/strict';
|
|
3
3
|
import fs from 'node:fs';
|
|
4
4
|
import os from 'node:os';
|
|
@@ -815,7 +815,7 @@ function assertAccountsWiring(projectRoot) {
|
|
|
815
815
|
path.join(projectRoot, 'packages', 'accounts-api', 'package.json'),
|
|
816
816
|
'utf8',
|
|
817
817
|
);
|
|
818
|
-
assert.match(accountsApiPackage, /@forgeon\/db-prisma/);
|
|
818
|
+
assert.match(accountsApiPackage, /@forgeon\/db-prisma/);
|
|
819
819
|
assert.match(accountsApiPackage, /@forgeon\/communications/);
|
|
820
820
|
|
|
821
821
|
const authStoreSource = fs.readFileSync(
|
|
@@ -2228,6 +2228,89 @@ describe('addModule', () => {
|
|
|
2228
2228
|
}
|
|
2229
2229
|
});
|
|
2230
2230
|
|
|
2231
|
+
it('applies communications on top of scaffold and wires SMTP-backed probe flow', () => {
|
|
2232
|
+
const targetRoot = mkTmp('forgeon-module-communications-');
|
|
2233
|
+
const projectRoot = path.join(targetRoot, 'demo-communications');
|
|
2234
|
+
const templateRoot = path.join(packageRoot, 'templates', 'base');
|
|
2235
|
+
|
|
2236
|
+
try {
|
|
2237
|
+
scaffoldProject({
|
|
2238
|
+
templateRoot,
|
|
2239
|
+
packageRoot,
|
|
2240
|
+
targetRoot: projectRoot,
|
|
2241
|
+
projectName: 'demo-communications',
|
|
2242
|
+
frontend: 'react',
|
|
2243
|
+
db: 'prisma',
|
|
2244
|
+
dbPrismaEnabled: true,
|
|
2245
|
+
i18nEnabled: true,
|
|
2246
|
+
proxy: 'caddy',
|
|
2247
|
+
});
|
|
2248
|
+
|
|
2249
|
+
const result = addModule({
|
|
2250
|
+
moduleId: 'communications',
|
|
2251
|
+
targetRoot: projectRoot,
|
|
2252
|
+
packageRoot,
|
|
2253
|
+
});
|
|
2254
|
+
|
|
2255
|
+
assert.equal(result.applied, true);
|
|
2256
|
+
|
|
2257
|
+
const appModule = fs.readFileSync(path.join(projectRoot, 'apps', 'api', 'src', 'app.module.ts'), 'utf8');
|
|
2258
|
+
assert.match(appModule, /communicationsConfig/);
|
|
2259
|
+
assert.match(appModule, /communicationsEnvSchema/);
|
|
2260
|
+
assert.match(appModule, /ForgeonCommunicationsModule\.register\(\)/);
|
|
2261
|
+
|
|
2262
|
+
const apiPackage = fs.readFileSync(path.join(projectRoot, 'apps', 'api', 'package.json'), 'utf8');
|
|
2263
|
+
assert.match(apiPackage, /@forgeon\/communications/);
|
|
2264
|
+
assert.match(apiPackage, /pnpm --filter @forgeon\/communications build/);
|
|
2265
|
+
|
|
2266
|
+
const communicationsEnv = fs.readFileSync(
|
|
2267
|
+
path.join(projectRoot, 'packages', 'communications', 'src', 'communications-env.schema.ts'),
|
|
2268
|
+
'utf8',
|
|
2269
|
+
);
|
|
2270
|
+
assert.match(communicationsEnv, /normalizeEnvBoolean/);
|
|
2271
|
+
assert.doesNotMatch(communicationsEnv, /COMMUNICATIONS_EMAIL_SMTP_SECURE: z\.coerce\.boolean/);
|
|
2272
|
+
|
|
2273
|
+
const communicationsConfig = fs.readFileSync(
|
|
2274
|
+
path.join(projectRoot, 'packages', 'communications', 'src', 'communications-config.loader.ts'),
|
|
2275
|
+
'utf8',
|
|
2276
|
+
);
|
|
2277
|
+
assert.match(communicationsConfig, /const derivedFrom = env\.COMMUNICATIONS_EMAIL_FROM \|\| env\.COMMUNICATIONS_EMAIL_SMTP_USER/);
|
|
2278
|
+
|
|
2279
|
+
const providerSource = fs.readFileSync(
|
|
2280
|
+
path.join(projectRoot, 'packages', 'communications', 'src', 'email', 'providers', 'gmail-smtp-email.provider.ts'),
|
|
2281
|
+
'utf8',
|
|
2282
|
+
);
|
|
2283
|
+
assert.match(providerSource, /COMMUNICATIONS_EMAIL_PROVIDER_SEND_FAILED/);
|
|
2284
|
+
assert.match(providerSource, /extractErrorDetails/);
|
|
2285
|
+
|
|
2286
|
+
const communicationsModuleSource = fs.readFileSync(
|
|
2287
|
+
path.join(projectRoot, 'packages', 'communications', 'src', 'forgeon-communications.module.ts'),
|
|
2288
|
+
'utf8',
|
|
2289
|
+
);
|
|
2290
|
+
assert.match(communicationsModuleSource, /@Global\(\)/);
|
|
2291
|
+
|
|
2292
|
+
const apiEnv = fs.readFileSync(path.join(projectRoot, 'apps', 'api', '.env.example'), 'utf8');
|
|
2293
|
+
assert.match(apiEnv, /COMMUNICATIONS_EMAIL_SMTP_SECURE=false/);
|
|
2294
|
+
assert.match(apiEnv, /^COMMUNICATIONS_EMAIL_FROM=$/m);
|
|
2295
|
+
|
|
2296
|
+
const dockerEnv = fs.readFileSync(path.join(projectRoot, 'infra', 'docker', '.env.example'), 'utf8');
|
|
2297
|
+
assert.match(dockerEnv, /^COMMUNICATIONS_EMAIL_FROM=$/m);
|
|
2298
|
+
|
|
2299
|
+
const probesTs = readWebProbes(projectRoot);
|
|
2300
|
+
assert.match(probesTs, /"id": "communications"/);
|
|
2301
|
+
assert.match(probesTs, /\$INPUT\.email\$/);
|
|
2302
|
+
|
|
2303
|
+
const readme = fs.readFileSync(path.join(projectRoot, 'README.md'), 'utf8');
|
|
2304
|
+
assert.match(readme, /## Communications Module/);
|
|
2305
|
+
assert.match(readme, /falls back to the SMTP user when left empty/);
|
|
2306
|
+
|
|
2307
|
+
const moduleDoc = fs.readFileSync(result.docsPath, 'utf8');
|
|
2308
|
+
assert.match(moduleDoc, /Status: implemented/);
|
|
2309
|
+
assert.match(moduleDoc, /STARTTLS/);
|
|
2310
|
+
} finally {
|
|
2311
|
+
fs.rmSync(targetRoot, { recursive: true, force: true });
|
|
2312
|
+
}
|
|
2313
|
+
});
|
|
2231
2314
|
it('applies accounts with db-prisma and wires the DB-backed runtime immediately', () => {
|
|
2232
2315
|
const targetRoot = mkTmp('forgeon-module-accounts-db-');
|
|
2233
2316
|
const projectRoot = path.join(targetRoot, 'demo-accounts-db');
|
|
@@ -2706,6 +2789,11 @@ describe('addModule', () => {
|
|
|
2706
2789
|
|
|
2707
2790
|
|
|
2708
2791
|
|
|
2792
|
+
|
|
2793
|
+
|
|
2794
|
+
|
|
2795
|
+
|
|
2796
|
+
|
|
2709
2797
|
|
|
2710
2798
|
|
|
2711
2799
|
|
package/src/modules/registry.mjs
CHANGED
|
@@ -67,10 +67,10 @@ const MODULE_PRESETS = {
|
|
|
67
67
|
category: 'file-storage',
|
|
68
68
|
implemented: true,
|
|
69
69
|
description:
|
|
70
|
-
'Resource-level access policy
|
|
70
|
+
'Resource-level access policy extension for the files core module. Requires files.',
|
|
71
71
|
detectionPaths: ['packages/files-access/package.json'],
|
|
72
72
|
provides: ['files-access-runtime'],
|
|
73
|
-
requires: [{ type: '
|
|
73
|
+
requires: [{ type: 'module', id: 'files' }],
|
|
74
74
|
optionalIntegrations: [],
|
|
75
75
|
docFragments: ['00_title', '10_overview', '20_scope', '90_status_implemented'],
|
|
76
76
|
},
|
|
@@ -80,10 +80,10 @@ const MODULE_PRESETS = {
|
|
|
80
80
|
category: 'file-storage',
|
|
81
81
|
implemented: true,
|
|
82
82
|
description:
|
|
83
|
-
'Owner-level upload quota
|
|
83
|
+
'Owner-level upload quota extension for the files core module. Requires files.',
|
|
84
84
|
detectionPaths: ['packages/files-quotas/package.json'],
|
|
85
85
|
provides: ['files-quotas-runtime'],
|
|
86
|
-
requires: [{ type: '
|
|
86
|
+
requires: [{ type: 'module', id: 'files' }],
|
|
87
87
|
optionalIntegrations: [],
|
|
88
88
|
docFragments: ['00_title', '10_overview', '20_scope', '90_status_implemented'],
|
|
89
89
|
},
|
|
@@ -93,10 +93,10 @@ const MODULE_PRESETS = {
|
|
|
93
93
|
category: 'file-storage',
|
|
94
94
|
implemented: true,
|
|
95
95
|
description:
|
|
96
|
-
'Image sanitation
|
|
96
|
+
'Image sanitation extension for the files core module (magic-bytes detect + sharp re-encode, metadata stripped by default). Requires files.',
|
|
97
97
|
detectionPaths: ['packages/files-image/package.json'],
|
|
98
98
|
provides: ['files-image-runtime'],
|
|
99
|
-
requires: [{ type: '
|
|
99
|
+
requires: [{ type: 'module', id: 'files' }],
|
|
100
100
|
optionalIntegrations: [],
|
|
101
101
|
docFragments: ['00_title', '10_overview', '20_scope', '90_status_implemented'],
|
|
102
102
|
},
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
## Scope
|
|
1
|
+
## Scope
|
|
2
2
|
|
|
3
3
|
Implemented scope:
|
|
4
4
|
|
|
@@ -14,7 +14,10 @@ Implemented scope:
|
|
|
14
14
|
- email channel with Gmail SMTP transport configuration
|
|
15
15
|
- sms stub channel
|
|
16
16
|
- push stub channel
|
|
17
|
-
5.
|
|
17
|
+
5. SMTP defaults:
|
|
18
|
+
- `COMMUNICATIONS_EMAIL_SMTP_SECURE=false` uses STARTTLS mode correctly on port `587`
|
|
19
|
+
- `COMMUNICATIONS_EMAIL_FROM` falls back to the authenticated SMTP user when left empty
|
|
20
|
+
6. Module checks:
|
|
18
21
|
- `GET /api/health/communications`
|
|
19
22
|
- `POST /api/health/communications`
|
|
20
23
|
- default web probe with email input + test send
|
package/templates/module-presets/accounts/packages/accounts-api/src/forgeon-accounts.module.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
2
|
DynamicModule,
|
|
3
3
|
Module,
|
|
4
4
|
ModuleMetadata,
|
|
@@ -6,7 +6,6 @@ import {
|
|
|
6
6
|
} from '@nestjs/common';
|
|
7
7
|
import { JwtModule } from '@nestjs/jwt';
|
|
8
8
|
import { PassportModule } from '@nestjs/passport';
|
|
9
|
-
import { ForgeonCommunicationsModule } from '@forgeon/communications';
|
|
10
9
|
import { DbPrismaModule } from '@forgeon/db-prisma';
|
|
11
10
|
import {
|
|
12
11
|
ACCOUNTS_AUTHZ_CLAIMS_RESOLVER,
|
|
@@ -41,7 +40,6 @@ export class ForgeonAccountsModule {
|
|
|
41
40
|
imports: [
|
|
42
41
|
AuthConfigModule,
|
|
43
42
|
DbPrismaModule,
|
|
44
|
-
ForgeonCommunicationsModule.register(),
|
|
45
43
|
PassportModule.register({ defaultStrategy: 'jwt' }),
|
|
46
44
|
JwtModule.register({}),
|
|
47
45
|
...(options.imports ?? []),
|
|
@@ -31,12 +31,13 @@ export const communicationsConfig = registerAs(
|
|
|
31
31
|
COMMUNICATIONS_CONFIG_NAMESPACE,
|
|
32
32
|
(): CommunicationsConfigValues => {
|
|
33
33
|
const env = parseCommunicationsEnv(process.env as unknown as Record<string, unknown>);
|
|
34
|
+
const derivedFrom = env.COMMUNICATIONS_EMAIL_FROM || env.COMMUNICATIONS_EMAIL_SMTP_USER;
|
|
34
35
|
|
|
35
36
|
return {
|
|
36
37
|
templatesRoot: path.resolve(process.cwd(), env.COMMUNICATIONS_TEMPLATES_ROOT),
|
|
37
38
|
email: {
|
|
38
39
|
provider: env.COMMUNICATIONS_EMAIL_PROVIDER,
|
|
39
|
-
from:
|
|
40
|
+
from: derivedFrom,
|
|
40
41
|
replyTo: env.COMMUNICATIONS_EMAIL_REPLY_TO || null,
|
|
41
42
|
subjectPrefix: env.COMMUNICATIONS_EMAIL_SUBJECT_PREFIX || null,
|
|
42
43
|
smtp: {
|
|
@@ -1,19 +1,35 @@
|
|
|
1
|
-
import { z } from 'zod';
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
|
|
3
|
+
function normalizeEnvBoolean(value: unknown): unknown {
|
|
4
|
+
if (typeof value === 'string') {
|
|
5
|
+
const normalized = value.trim().toLowerCase();
|
|
6
|
+
if (['true', '1', 'yes', 'on'].includes(normalized)) {
|
|
7
|
+
return true;
|
|
8
|
+
}
|
|
9
|
+
if (['false', '0', 'no', 'off', ''].includes(normalized)) {
|
|
10
|
+
return false;
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
return value;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const envBoolean = z.preprocess(normalizeEnvBoolean, z.boolean());
|
|
2
18
|
|
|
3
19
|
export const communicationsEnvSchema = z
|
|
4
20
|
.object({
|
|
5
21
|
COMMUNICATIONS_TEMPLATES_ROOT: z.string().trim().min(1).default('resources/communications'),
|
|
6
22
|
COMMUNICATIONS_EMAIL_PROVIDER: z.enum(['gmail-smtp']).default('gmail-smtp'),
|
|
7
|
-
COMMUNICATIONS_EMAIL_FROM: z.string().trim().
|
|
23
|
+
COMMUNICATIONS_EMAIL_FROM: z.string().trim().default(''),
|
|
8
24
|
COMMUNICATIONS_EMAIL_REPLY_TO: z.string().trim().default(''),
|
|
9
25
|
COMMUNICATIONS_EMAIL_SUBJECT_PREFIX: z.string().trim().default('[Forgeon]'),
|
|
10
26
|
COMMUNICATIONS_EMAIL_SMTP_HOST: z.string().trim().min(1).default('smtp.gmail.com'),
|
|
11
27
|
COMMUNICATIONS_EMAIL_SMTP_PORT: z.coerce.number().int().min(1).max(65535).default(587),
|
|
12
|
-
COMMUNICATIONS_EMAIL_SMTP_SECURE:
|
|
28
|
+
COMMUNICATIONS_EMAIL_SMTP_SECURE: envBoolean.default(false),
|
|
13
29
|
COMMUNICATIONS_EMAIL_SMTP_USER: z.string().trim().default(''),
|
|
14
30
|
COMMUNICATIONS_EMAIL_SMTP_PASS: z.string().trim().default(''),
|
|
15
31
|
COMMUNICATIONS_SMS_PROVIDER: z.enum(['stub']).default('stub'),
|
|
16
|
-
COMMUNICATIONS_PUSH_PROVIDER: z.enum(['stub']).default('stub')
|
|
32
|
+
COMMUNICATIONS_PUSH_PROVIDER: z.enum(['stub']).default('stub'),
|
|
17
33
|
})
|
|
18
34
|
.passthrough();
|
|
19
35
|
|
|
@@ -21,4 +37,4 @@ export type CommunicationsEnv = z.infer<typeof communicationsEnvSchema>;
|
|
|
21
37
|
|
|
22
38
|
export function parseCommunicationsEnv(input: Record<string, unknown>): CommunicationsEnv {
|
|
23
39
|
return communicationsEnvSchema.parse(input);
|
|
24
|
-
}
|
|
40
|
+
}
|
|
@@ -6,6 +6,7 @@ import type { EmailProvider, EmailProviderSendInput, EmailProviderSendResult } f
|
|
|
6
6
|
|
|
7
7
|
const EMAIL_ERROR_CODES = {
|
|
8
8
|
providerNotConfigured: 'COMMUNICATIONS_EMAIL_PROVIDER_NOT_CONFIGURED',
|
|
9
|
+
providerSendFailed: 'COMMUNICATIONS_EMAIL_PROVIDER_SEND_FAILED',
|
|
9
10
|
} as const;
|
|
10
11
|
|
|
11
12
|
@Injectable()
|
|
@@ -30,20 +31,33 @@ export class GmailSmtpEmailProvider implements EmailProvider {
|
|
|
30
31
|
});
|
|
31
32
|
}
|
|
32
33
|
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
34
|
+
try {
|
|
35
|
+
const response = await this.getTransporter().sendMail({
|
|
36
|
+
from: this.configService.emailFrom,
|
|
37
|
+
to: input.to,
|
|
38
|
+
subject: input.subject,
|
|
39
|
+
html: input.html,
|
|
40
|
+
replyTo: input.replyTo ?? this.configService.emailReplyTo ?? undefined,
|
|
41
|
+
});
|
|
40
42
|
|
|
41
|
-
|
|
43
|
+
this.logger.log(`email.sent provider=${this.providerId} to=${input.to} messageId=${response.messageId ?? 'n/a'}`);
|
|
42
44
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
45
|
+
return {
|
|
46
|
+
status: 'sent',
|
|
47
|
+
messageId: response.messageId ?? null,
|
|
48
|
+
};
|
|
49
|
+
} catch (error) {
|
|
50
|
+
const details = this.extractErrorDetails(error);
|
|
51
|
+
this.logger.error(`email.failed provider=${this.providerId} to=${input.to} details=${JSON.stringify(details)}`);
|
|
52
|
+
throw new ServiceUnavailableException({
|
|
53
|
+
message: 'Email delivery failed',
|
|
54
|
+
details: {
|
|
55
|
+
code: EMAIL_ERROR_CODES.providerSendFailed,
|
|
56
|
+
provider: this.providerId,
|
|
57
|
+
...details,
|
|
58
|
+
},
|
|
59
|
+
});
|
|
60
|
+
}
|
|
47
61
|
}
|
|
48
62
|
|
|
49
63
|
private getTransporter(): Transporter {
|
|
@@ -61,4 +75,30 @@ export class GmailSmtpEmailProvider implements EmailProvider {
|
|
|
61
75
|
|
|
62
76
|
return this.transporter;
|
|
63
77
|
}
|
|
78
|
+
|
|
79
|
+
private extractErrorDetails(error: unknown): Record<string, unknown> {
|
|
80
|
+
if (!error || typeof error !== 'object') {
|
|
81
|
+
return { raw: String(error) };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const candidate = error as {
|
|
85
|
+
code?: unknown;
|
|
86
|
+
command?: unknown;
|
|
87
|
+
response?: unknown;
|
|
88
|
+
responseCode?: unknown;
|
|
89
|
+
errno?: unknown;
|
|
90
|
+
syscall?: unknown;
|
|
91
|
+
message?: unknown;
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
return {
|
|
95
|
+
message: typeof candidate.message === 'string' ? candidate.message : String(error),
|
|
96
|
+
code: candidate.code ?? null,
|
|
97
|
+
command: candidate.command ?? null,
|
|
98
|
+
responseCode: candidate.responseCode ?? null,
|
|
99
|
+
response: candidate.response ?? null,
|
|
100
|
+
errno: candidate.errno ?? null,
|
|
101
|
+
syscall: candidate.syscall ?? null,
|
|
102
|
+
};
|
|
103
|
+
}
|
|
64
104
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { DynamicModule, Module, ModuleMetadata } from '@nestjs/common';
|
|
1
|
+
import { DynamicModule, Global, Module, ModuleMetadata } from '@nestjs/common';
|
|
2
2
|
import {
|
|
3
3
|
COMMUNICATIONS_EMAIL_PROVIDER,
|
|
4
4
|
COMMUNICATIONS_PUSH_PROVIDER,
|
|
@@ -20,6 +20,7 @@ export interface ForgeonCommunicationsModuleOptions {
|
|
|
20
20
|
imports?: ModuleMetadata['imports'];
|
|
21
21
|
}
|
|
22
22
|
|
|
23
|
+
@Global()
|
|
23
24
|
@Module({})
|
|
24
25
|
export class ForgeonCommunicationsModule {
|
|
25
26
|
static register(options: ForgeonCommunicationsModuleOptions = {}): DynamicModule {
|