@kelpie/server 0.4.0 → 0.4.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/dist/lib/config.d.ts +5 -0
- package/dist/lib/config.d.ts.map +1 -1
- package/dist/lib/config.js +21 -12
- package/dist/lib/config.js.map +1 -1
- package/dist/lib/email.d.ts +53 -9
- package/dist/lib/email.d.ts.map +1 -1
- package/dist/lib/email.js +49 -4
- package/dist/lib/email.js.map +1 -1
- package/package.json +4 -2
- package/src/lib/config.ts +22 -12
- package/src/lib/email.ts +75 -7
package/dist/lib/config.d.ts
CHANGED
|
@@ -37,6 +37,11 @@ export declare class ConfigurationError extends Error {
|
|
|
37
37
|
/**
|
|
38
38
|
* Parses an environment into a validated config.
|
|
39
39
|
*
|
|
40
|
+
* `emailConfigSchema` is a discriminated union, so it is parsed separately
|
|
41
|
+
* from the rest: a union has no flat `.shape` to spread into `environmentSchema`,
|
|
42
|
+
* only its own `EMAIL_PROVIDER`-keyed branches. Problems from both parses are
|
|
43
|
+
* combined into one error, preserving the "every missing variable at once" rule.
|
|
44
|
+
*
|
|
40
45
|
* @param environment Raw variables, normally `process.env`.
|
|
41
46
|
* @throws ConfigurationError listing every invalid or missing variable.
|
|
42
47
|
*/
|
package/dist/lib/config.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"config.d.ts","sourceRoot":"","sources":["../../src/lib/config.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,YAAY,CAAA;AAG7C,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,gBAAgB,CAAA;AAErD;;;;GAIG;AAEH,MAAM,MAAM,WAAW,GAAG,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,SAAS,CAAC,CAAC,CAAA;AAEtE,MAAM,MAAM,WAAW,GAAG,aAAa,GAAG,MAAM,GAAG,YAAY,CAAA;AAE/D,MAAM,MAAM,QAAQ,GAAG,OAAO,GAAG,MAAM,GAAG,MAAM,GAAG,OAAO,CAAA;AAE1D,MAAM,WAAW,YAAY;IAC3B,QAAQ,CAAC,WAAW,EAAE,WAAW,CAAA;IACjC,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;IACrB,QAAQ,CAAC,WAAW,EAAE,MAAM,CAAA;IAC5B,QAAQ,CAAC,QAAQ,EAAE,QAAQ,CAAA;IAC3B,gFAAgF;IAChF,QAAQ,CAAC,KAAK,EAAE,WAAW,CAAA;IAC3B;;;;OAIG;IACH,QAAQ,CAAC,gBAAgB,EAAE,MAAM,GAAG,SAAS,CAAA;IAC7C;;;;OAIG;IACH,QAAQ,CAAC,kBAAkB,EAAE,MAAM,GAAG,SAAS,CAAA;IAC/C,QAAQ,CAAC,SAAS,EAAE,eAAe,CAAA;CACpC;AAED,gFAAgF;AAChF,qBAAa,kBAAmB,SAAQ,KAAK;IAC3C,QAAQ,CAAC,QAAQ,EAAE,SAAS,MAAM,EAAE,CAAA;IAEpC,YAAY,QAAQ,EAAE,SAAS,MAAM,EAAE,EAItC;CACF;
|
|
1
|
+
{"version":3,"file":"config.d.ts","sourceRoot":"","sources":["../../src/lib/config.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,YAAY,CAAA;AAG7C,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,gBAAgB,CAAA;AAErD;;;;GAIG;AAEH,MAAM,MAAM,WAAW,GAAG,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,SAAS,CAAC,CAAC,CAAA;AAEtE,MAAM,MAAM,WAAW,GAAG,aAAa,GAAG,MAAM,GAAG,YAAY,CAAA;AAE/D,MAAM,MAAM,QAAQ,GAAG,OAAO,GAAG,MAAM,GAAG,MAAM,GAAG,OAAO,CAAA;AAE1D,MAAM,WAAW,YAAY;IAC3B,QAAQ,CAAC,WAAW,EAAE,WAAW,CAAA;IACjC,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;IACrB,QAAQ,CAAC,WAAW,EAAE,MAAM,CAAA;IAC5B,QAAQ,CAAC,QAAQ,EAAE,QAAQ,CAAA;IAC3B,gFAAgF;IAChF,QAAQ,CAAC,KAAK,EAAE,WAAW,CAAA;IAC3B;;;;OAIG;IACH,QAAQ,CAAC,gBAAgB,EAAE,MAAM,GAAG,SAAS,CAAA;IAC7C;;;;OAIG;IACH,QAAQ,CAAC,kBAAkB,EAAE,MAAM,GAAG,SAAS,CAAA;IAC/C,QAAQ,CAAC,SAAS,EAAE,eAAe,CAAA;CACpC;AAED,gFAAgF;AAChF,qBAAa,kBAAmB,SAAQ,KAAK;IAC3C,QAAQ,CAAC,QAAQ,EAAE,SAAS,MAAM,EAAE,CAAA;IAEpC,YAAY,QAAQ,EAAE,SAAS,MAAM,EAAE,EAItC;CACF;AAuBD;;;;;;;;;;GAUG;AACH,wBAAgB,UAAU,CAAC,WAAW,EAAE,WAAW,GAAG,YAAY,CAuBjE"}
|
package/dist/lib/config.js
CHANGED
|
@@ -29,29 +29,38 @@ const environmentSchema = z.object({
|
|
|
29
29
|
LOG_LEVEL: z.enum(['debug', 'info', 'warn', 'error']),
|
|
30
30
|
KELPIE_MODULE_CONFIG_PATH: z.string().min(1).optional(),
|
|
31
31
|
WEB_BUNDLE_DIR: z.string().min(1).optional(),
|
|
32
|
-
...emailConfigSchema.shape,
|
|
33
32
|
...rateLimitConfigSchema.shape,
|
|
34
33
|
});
|
|
35
34
|
/**
|
|
36
35
|
* Parses an environment into a validated config.
|
|
37
36
|
*
|
|
37
|
+
* `emailConfigSchema` is a discriminated union, so it is parsed separately
|
|
38
|
+
* from the rest: a union has no flat `.shape` to spread into `environmentSchema`,
|
|
39
|
+
* only its own `EMAIL_PROVIDER`-keyed branches. Problems from both parses are
|
|
40
|
+
* combined into one error, preserving the "every missing variable at once" rule.
|
|
41
|
+
*
|
|
38
42
|
* @param environment Raw variables, normally `process.env`.
|
|
39
43
|
* @throws ConfigurationError listing every invalid or missing variable.
|
|
40
44
|
*/
|
|
41
45
|
export function loadConfig(environment) {
|
|
42
|
-
const
|
|
43
|
-
|
|
44
|
-
|
|
46
|
+
const environmentResult = environmentSchema.safeParse(environment);
|
|
47
|
+
const emailResult = emailConfigSchema.safeParse(environment);
|
|
48
|
+
if (!environmentResult.success || !emailResult.success) {
|
|
49
|
+
const problems = [
|
|
50
|
+
...(environmentResult.success ? [] : environmentResult.error.issues.map(describeValidationIssue)),
|
|
51
|
+
...(emailResult.success ? [] : emailResult.error.issues.map(describeValidationIssue)),
|
|
52
|
+
];
|
|
53
|
+
throw new ConfigurationError(problems);
|
|
45
54
|
}
|
|
46
55
|
return {
|
|
47
|
-
runtimeMode:
|
|
48
|
-
port:
|
|
49
|
-
databaseUrl:
|
|
50
|
-
logLevel:
|
|
51
|
-
email:
|
|
52
|
-
moduleConfigPath:
|
|
53
|
-
webBundleDirectory:
|
|
54
|
-
rateLimit: rateLimitConfigFrom(
|
|
56
|
+
runtimeMode: environmentResult.data.NODE_ENV,
|
|
57
|
+
port: environmentResult.data.PORT,
|
|
58
|
+
databaseUrl: environmentResult.data.DATABASE_URL,
|
|
59
|
+
logLevel: environmentResult.data.LOG_LEVEL,
|
|
60
|
+
email: emailResult.data,
|
|
61
|
+
moduleConfigPath: environmentResult.data.KELPIE_MODULE_CONFIG_PATH,
|
|
62
|
+
webBundleDirectory: environmentResult.data.WEB_BUNDLE_DIR,
|
|
63
|
+
rateLimit: rateLimitConfigFrom(environmentResult.data),
|
|
55
64
|
};
|
|
56
65
|
}
|
|
57
66
|
//# sourceMappingURL=config.js.map
|
package/dist/lib/config.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"config.js","sourceRoot":"","sources":["../../src/lib/config.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAA;AAEvB,OAAO,EAAE,iBAAiB,EAAE,MAAM,YAAY,CAAA;AAE9C,OAAO,EAAE,uBAAuB,EAAE,MAAM,aAAa,CAAA;AACrD,OAAO,EAAE,mBAAmB,EAAE,qBAAqB,EAAE,MAAM,gBAAgB,CAAA;AAqC3E,gFAAgF;AAChF,MAAM,OAAO,kBAAmB,SAAQ,KAAK;IAClC,QAAQ,CAAmB;IAEpC,YAAY,QAA2B;QACrC,KAAK,CAAC,uCAAuC,QAAQ,CAAC,GAAG,CAAC,CAAC,OAAO,EAAE,EAAE,CAAC,OAAO,OAAO,EAAE,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,EAAE,CAAC,CAAA;QACtG,IAAI,CAAC,IAAI,GAAG,oBAAoB,CAAA;QAChC,IAAI,CAAC,QAAQ,GAAG,QAAQ,CAAA;IAC1B,CAAC;CACF;AAED,SAAS,aAAa,CAAC,KAAa;IAClC,IAAI,CAAC;QACH,MAAM,EAAE,QAAQ,EAAE,GAAG,IAAI,GAAG,CAAC,KAAK,CAAC,CAAA;QACnC,OAAO,QAAQ,KAAK,WAAW,IAAI,QAAQ,KAAK,aAAa,CAAA;IAC/D,CAAC;IAAC,MAAM,CAAC;QACP,OAAO,KAAK,CAAA;IACd,CAAC;AACH,CAAC;AAED,MAAM,iBAAiB,GAAG,CAAC,CAAC,MAAM,CAAC;IACjC,QAAQ,EAAE,CAAC,CAAC,IAAI,CAAC,CAAC,aAAa,EAAE,MAAM,EAAE,YAAY,CAAC,CAAC;IACvD,IAAI,EAAE,CAAC,CAAC,MAAM,CAAC,MAAM,EAAE,CAAC,GAAG,EAAE,CAAC,QAAQ,EAAE,CAAC,GAAG,CAAC,KAAK,CAAC;IACnD,YAAY,EAAE,CAAC;SACZ,MAAM,EAAE;SACR,MAAM,CAAC,aAAa,EAAE,EAAE,OAAO,EAAE,0DAA0D,EAAE,CAAC;IACjG,SAAS,EAAE,CAAC,CAAC,IAAI,CAAC,CAAC,OAAO,EAAE,MAAM,EAAE,MAAM,EAAE,OAAO,CAAC,CAAC;IACrD,yBAAyB,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,QAAQ,EAAE;IACvD,cAAc,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,QAAQ,EAAE;IAC5C,GAAG,
|
|
1
|
+
{"version":3,"file":"config.js","sourceRoot":"","sources":["../../src/lib/config.ts"],"names":[],"mappings":"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"}
|
package/dist/lib/email.d.ts
CHANGED
|
@@ -5,9 +5,15 @@ import type { Logger } from './logger.ts';
|
|
|
5
5
|
* and account-change notifications, nothing else. Kelpie never sends outreach
|
|
6
6
|
* email.
|
|
7
7
|
*
|
|
8
|
-
* Roadmap decision 4: the provider is configured, never hardcoded. Core ships
|
|
9
|
-
* port and
|
|
10
|
-
*
|
|
8
|
+
* Roadmap decision 4: the provider is configured, never hardcoded. Core ships
|
|
9
|
+
* the port and two providers, `log` and `smtp`. Both need no vendor account a
|
|
10
|
+
* deployment doesn't already have, which is what keeps them in core rather than
|
|
11
|
+
* a module, per `modules.md`'s split test. A provider that does need one
|
|
12
|
+
* (Resend, Postmark, Mailtrap, SendGrid) is a commercial integration under that
|
|
13
|
+
* same test and belongs in a module: it supplies its own `EmailSender` from its
|
|
14
|
+
* own config, and an assembly's entry point wires it into `services.email` in
|
|
15
|
+
* place of `createEmailSender`, the same way `kelpie-cloud/src/server.ts` wires
|
|
16
|
+
* this one today.
|
|
11
17
|
*/
|
|
12
18
|
export interface EmailMessage {
|
|
13
19
|
readonly to: string;
|
|
@@ -17,13 +23,35 @@ export interface EmailMessage {
|
|
|
17
23
|
export interface EmailSender {
|
|
18
24
|
send(message: EmailMessage): Promise<void>;
|
|
19
25
|
}
|
|
20
|
-
|
|
21
|
-
EMAIL_PROVIDER: z.
|
|
22
|
-
log: "log";
|
|
23
|
-
}>;
|
|
26
|
+
declare const smtpEmailConfigSchema: z.ZodObject<{
|
|
27
|
+
EMAIL_PROVIDER: z.ZodLiteral<"smtp">;
|
|
24
28
|
EMAIL_FROM: z.ZodString;
|
|
29
|
+
SMTP_HOST: z.ZodString;
|
|
30
|
+
SMTP_PORT: z.ZodCoercedNumber<unknown>;
|
|
31
|
+
SMTP_SECURE: z.ZodPipe<z.ZodEnum<{
|
|
32
|
+
false: "false";
|
|
33
|
+
true: "true";
|
|
34
|
+
}>, z.ZodTransform<boolean, "false" | "true">>;
|
|
35
|
+
SMTP_USER: z.ZodString;
|
|
36
|
+
SMTP_PASSWORD: z.ZodString;
|
|
25
37
|
}, z.core.$strip>;
|
|
38
|
+
export declare const emailConfigSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
|
|
39
|
+
EMAIL_PROVIDER: z.ZodLiteral<"log">;
|
|
40
|
+
EMAIL_FROM: z.ZodString;
|
|
41
|
+
}, z.core.$strip>, z.ZodObject<{
|
|
42
|
+
EMAIL_PROVIDER: z.ZodLiteral<"smtp">;
|
|
43
|
+
EMAIL_FROM: z.ZodString;
|
|
44
|
+
SMTP_HOST: z.ZodString;
|
|
45
|
+
SMTP_PORT: z.ZodCoercedNumber<unknown>;
|
|
46
|
+
SMTP_SECURE: z.ZodPipe<z.ZodEnum<{
|
|
47
|
+
false: "false";
|
|
48
|
+
true: "true";
|
|
49
|
+
}>, z.ZodTransform<boolean, "false" | "true">>;
|
|
50
|
+
SMTP_USER: z.ZodString;
|
|
51
|
+
SMTP_PASSWORD: z.ZodString;
|
|
52
|
+
}, z.core.$strip>], "EMAIL_PROVIDER">;
|
|
26
53
|
export type EmailConfig = z.infer<typeof emailConfigSchema>;
|
|
54
|
+
export type SmtpEmailConfig = z.infer<typeof smtpEmailConfigSchema>;
|
|
27
55
|
/**
|
|
28
56
|
* Writes the message to the log instead of sending it. For self-hosted
|
|
29
57
|
* development, where the reset link in the log is exactly what you want.
|
|
@@ -32,11 +60,27 @@ export type EmailConfig = z.infer<typeof emailConfigSchema>;
|
|
|
32
60
|
* so nobody reaches this provider without naming it.
|
|
33
61
|
*/
|
|
34
62
|
export declare function createLogEmailSender(logger: Logger, from: string): EmailSender;
|
|
63
|
+
/**
|
|
64
|
+
* What `createSmtpEmailSender` sends a message through. A real deployment gets
|
|
65
|
+
* one backed by `nodemailer`; a test injects one that records calls, so the
|
|
66
|
+
* sender is verifiable without a container running a real SMTP conversation.
|
|
67
|
+
*/
|
|
68
|
+
export interface SmtpTransport {
|
|
69
|
+
sendMail(message: {
|
|
70
|
+
from: string;
|
|
71
|
+
to: string;
|
|
72
|
+
subject: string;
|
|
73
|
+
text: string;
|
|
74
|
+
}): Promise<unknown>;
|
|
75
|
+
}
|
|
76
|
+
/** Sends over SMTP. `transport` defaults to a real connection built from `config`. */
|
|
77
|
+
export declare function createSmtpEmailSender(config: SmtpEmailConfig, logger: Logger, transport?: SmtpTransport): EmailSender;
|
|
35
78
|
/**
|
|
36
79
|
* Builds the configured sender.
|
|
37
80
|
*
|
|
38
|
-
* @throws Never. An unknown provider cannot reach here: the config schema is
|
|
39
|
-
*
|
|
81
|
+
* @throws Never. An unknown provider cannot reach here: the config schema is a
|
|
82
|
+
* discriminated union, so boot rejects it first.
|
|
40
83
|
*/
|
|
41
84
|
export declare function createEmailSender(config: EmailConfig, logger: Logger): EmailSender;
|
|
85
|
+
export {};
|
|
42
86
|
//# sourceMappingURL=email.d.ts.map
|
package/dist/lib/email.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"email.d.ts","sourceRoot":"","sources":["../../src/lib/email.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"email.d.ts","sourceRoot":"","sources":["../../src/lib/email.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAA;AAGvB,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,aAAa,CAAA;AAEzC;;;;;;;;;;;;;;GAcG;AAEH,MAAM,WAAW,YAAY;IAC3B,QAAQ,CAAC,EAAE,EAAE,MAAM,CAAA;IACnB,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAA;IACxB,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;CACtB;AAED,MAAM,WAAW,WAAW;IAC1B,IAAI,CAAC,OAAO,EAAE,YAAY,GAAG,OAAO,CAAC,IAAI,CAAC,CAAA;CAC3C;AAOD,QAAA,MAAM,qBAAqB;;;;;;;;;;;iBAQzB,CAAA;AAEF,eAAO,MAAM,iBAAiB;;;;;;;;;;;;;;qCAG5B,CAAA;AAEF,MAAM,MAAM,WAAW,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,iBAAiB,CAAC,CAAA;AAC3D,MAAM,MAAM,eAAe,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,qBAAqB,CAAC,CAAA;AAEnE;;;;;;GAMG;AACH,wBAAgB,oBAAoB,CAAC,MAAM,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,WAAW,CAa9E;AAED;;;;GAIG;AACH,MAAM,WAAW,aAAa;IAC5B,QAAQ,CAAC,OAAO,EAAE;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,EAAE,EAAE,MAAM,CAAC;QAAC,OAAO,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,MAAM,CAAA;KAAE,GAAG,OAAO,CAAC,OAAO,CAAC,CAAA;CACjG;AAWD,sFAAsF;AACtF,wBAAgB,qBAAqB,CACnC,MAAM,EAAE,eAAe,EACvB,MAAM,EAAE,MAAM,EACd,SAAS,GAAE,aAAiD,GAC3D,WAAW,CAiBb;AAED;;;;;GAKG;AACH,wBAAgB,iBAAiB,CAAC,MAAM,EAAE,WAAW,EAAE,MAAM,EAAE,MAAM,GAAG,WAAW,CAOlF"}
|
package/dist/lib/email.js
CHANGED
|
@@ -1,8 +1,23 @@
|
|
|
1
|
+
import nodemailer from 'nodemailer';
|
|
1
2
|
import { z } from 'zod';
|
|
2
|
-
|
|
3
|
-
|
|
3
|
+
import { describeThrown } from './errors.js';
|
|
4
|
+
const logEmailConfigSchema = z.object({
|
|
5
|
+
EMAIL_PROVIDER: z.literal('log'),
|
|
4
6
|
EMAIL_FROM: z.string().min(1),
|
|
5
7
|
});
|
|
8
|
+
const smtpEmailConfigSchema = z.object({
|
|
9
|
+
EMAIL_PROVIDER: z.literal('smtp'),
|
|
10
|
+
EMAIL_FROM: z.string().min(1),
|
|
11
|
+
SMTP_HOST: z.string().min(1),
|
|
12
|
+
SMTP_PORT: z.coerce.number().int().positive().max(65535),
|
|
13
|
+
SMTP_SECURE: z.enum(['true', 'false']).transform((value) => value === 'true'),
|
|
14
|
+
SMTP_USER: z.string().min(1),
|
|
15
|
+
SMTP_PASSWORD: z.string().min(1),
|
|
16
|
+
});
|
|
17
|
+
export const emailConfigSchema = z.discriminatedUnion('EMAIL_PROVIDER', [
|
|
18
|
+
logEmailConfigSchema,
|
|
19
|
+
smtpEmailConfigSchema,
|
|
20
|
+
]);
|
|
6
21
|
/**
|
|
7
22
|
* Writes the message to the log instead of sending it. For self-hosted
|
|
8
23
|
* development, where the reset link in the log is exactly what you want.
|
|
@@ -23,16 +38,46 @@ export function createLogEmailSender(logger, from) {
|
|
|
23
38
|
},
|
|
24
39
|
};
|
|
25
40
|
}
|
|
41
|
+
function createNodemailerTransport(config) {
|
|
42
|
+
return nodemailer.createTransport({
|
|
43
|
+
host: config.SMTP_HOST,
|
|
44
|
+
port: config.SMTP_PORT,
|
|
45
|
+
secure: config.SMTP_SECURE,
|
|
46
|
+
auth: { user: config.SMTP_USER, pass: config.SMTP_PASSWORD },
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
/** Sends over SMTP. `transport` defaults to a real connection built from `config`. */
|
|
50
|
+
export function createSmtpEmailSender(config, logger, transport = createNodemailerTransport(config)) {
|
|
51
|
+
return {
|
|
52
|
+
async send(message) {
|
|
53
|
+
try {
|
|
54
|
+
await transport.sendMail({
|
|
55
|
+
from: config.EMAIL_FROM,
|
|
56
|
+
to: message.to,
|
|
57
|
+
subject: message.subject,
|
|
58
|
+
text: message.body,
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
catch (cause) {
|
|
62
|
+
const reason = describeThrown(cause);
|
|
63
|
+
logger.error('smtp send failed', { to: message.to, reason });
|
|
64
|
+
throw new Error(`Failed to send email to ${message.to} over SMTP: ${reason}`, { cause });
|
|
65
|
+
}
|
|
66
|
+
},
|
|
67
|
+
};
|
|
68
|
+
}
|
|
26
69
|
/**
|
|
27
70
|
* Builds the configured sender.
|
|
28
71
|
*
|
|
29
|
-
* @throws Never. An unknown provider cannot reach here: the config schema is
|
|
30
|
-
*
|
|
72
|
+
* @throws Never. An unknown provider cannot reach here: the config schema is a
|
|
73
|
+
* discriminated union, so boot rejects it first.
|
|
31
74
|
*/
|
|
32
75
|
export function createEmailSender(config, logger) {
|
|
33
76
|
switch (config.EMAIL_PROVIDER) {
|
|
34
77
|
case 'log':
|
|
35
78
|
return createLogEmailSender(logger, config.EMAIL_FROM);
|
|
79
|
+
case 'smtp':
|
|
80
|
+
return createSmtpEmailSender(config, logger);
|
|
36
81
|
}
|
|
37
82
|
}
|
|
38
83
|
//# sourceMappingURL=email.js.map
|
package/dist/lib/email.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"email.js","sourceRoot":"","sources":["../../src/lib/email.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAA;
|
|
1
|
+
{"version":3,"file":"email.js","sourceRoot":"","sources":["../../src/lib/email.ts"],"names":[],"mappings":"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"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kelpie/server",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.1",
|
|
4
4
|
"description": "The Kelpie service as a library: module runtime, core CRM modules, REST API, MCP surface, and the shared migration pipeline.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"kelpie",
|
|
@@ -52,16 +52,18 @@
|
|
|
52
52
|
},
|
|
53
53
|
"dependencies": {
|
|
54
54
|
"@hono/node-server": "^2.0.12",
|
|
55
|
-
"@kelpie/schemas": "^0.4.
|
|
55
|
+
"@kelpie/schemas": "^0.4.1",
|
|
56
56
|
"@node-rs/argon2": "^2.0.2",
|
|
57
57
|
"drizzle-orm": "^0.45.2",
|
|
58
58
|
"hono": "^4.12.32",
|
|
59
|
+
"nodemailer": "^9.0.5",
|
|
59
60
|
"postgres": "^3.4.9",
|
|
60
61
|
"ulid": "^3.0.2",
|
|
61
62
|
"zod": "^4.4.3"
|
|
62
63
|
},
|
|
63
64
|
"devDependencies": {
|
|
64
65
|
"@types/node": "^26.1.2",
|
|
66
|
+
"@types/nodemailer": "^8.0.1",
|
|
65
67
|
"drizzle-kit": "^0.31.10",
|
|
66
68
|
"vite": "^8.2.0",
|
|
67
69
|
"vitest": "^4.1.10"
|
package/src/lib/config.ts
CHANGED
|
@@ -69,31 +69,41 @@ const environmentSchema = z.object({
|
|
|
69
69
|
LOG_LEVEL: z.enum(['debug', 'info', 'warn', 'error']),
|
|
70
70
|
KELPIE_MODULE_CONFIG_PATH: z.string().min(1).optional(),
|
|
71
71
|
WEB_BUNDLE_DIR: z.string().min(1).optional(),
|
|
72
|
-
...emailConfigSchema.shape,
|
|
73
72
|
...rateLimitConfigSchema.shape,
|
|
74
73
|
})
|
|
75
74
|
|
|
76
75
|
/**
|
|
77
76
|
* Parses an environment into a validated config.
|
|
78
77
|
*
|
|
78
|
+
* `emailConfigSchema` is a discriminated union, so it is parsed separately
|
|
79
|
+
* from the rest: a union has no flat `.shape` to spread into `environmentSchema`,
|
|
80
|
+
* only its own `EMAIL_PROVIDER`-keyed branches. Problems from both parses are
|
|
81
|
+
* combined into one error, preserving the "every missing variable at once" rule.
|
|
82
|
+
*
|
|
79
83
|
* @param environment Raw variables, normally `process.env`.
|
|
80
84
|
* @throws ConfigurationError listing every invalid or missing variable.
|
|
81
85
|
*/
|
|
82
86
|
export function loadConfig(environment: Environment): KelpieConfig {
|
|
83
|
-
const
|
|
87
|
+
const environmentResult = environmentSchema.safeParse(environment)
|
|
88
|
+
const emailResult = emailConfigSchema.safeParse(environment)
|
|
89
|
+
|
|
90
|
+
if (!environmentResult.success || !emailResult.success) {
|
|
91
|
+
const problems = [
|
|
92
|
+
...(environmentResult.success ? [] : environmentResult.error.issues.map(describeValidationIssue)),
|
|
93
|
+
...(emailResult.success ? [] : emailResult.error.issues.map(describeValidationIssue)),
|
|
94
|
+
]
|
|
84
95
|
|
|
85
|
-
|
|
86
|
-
throw new ConfigurationError(result.error.issues.map(describeValidationIssue))
|
|
96
|
+
throw new ConfigurationError(problems)
|
|
87
97
|
}
|
|
88
98
|
|
|
89
99
|
return {
|
|
90
|
-
runtimeMode:
|
|
91
|
-
port:
|
|
92
|
-
databaseUrl:
|
|
93
|
-
logLevel:
|
|
94
|
-
email:
|
|
95
|
-
moduleConfigPath:
|
|
96
|
-
webBundleDirectory:
|
|
97
|
-
rateLimit: rateLimitConfigFrom(
|
|
100
|
+
runtimeMode: environmentResult.data.NODE_ENV,
|
|
101
|
+
port: environmentResult.data.PORT,
|
|
102
|
+
databaseUrl: environmentResult.data.DATABASE_URL,
|
|
103
|
+
logLevel: environmentResult.data.LOG_LEVEL,
|
|
104
|
+
email: emailResult.data,
|
|
105
|
+
moduleConfigPath: environmentResult.data.KELPIE_MODULE_CONFIG_PATH,
|
|
106
|
+
webBundleDirectory: environmentResult.data.WEB_BUNDLE_DIR,
|
|
107
|
+
rateLimit: rateLimitConfigFrom(environmentResult.data),
|
|
98
108
|
}
|
|
99
109
|
}
|
package/src/lib/email.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
|
+
import nodemailer from 'nodemailer'
|
|
1
2
|
import { z } from 'zod'
|
|
2
3
|
|
|
4
|
+
import { describeThrown } from './errors.ts'
|
|
3
5
|
import type { Logger } from './logger.ts'
|
|
4
6
|
|
|
5
7
|
/**
|
|
@@ -7,9 +9,15 @@ import type { Logger } from './logger.ts'
|
|
|
7
9
|
* and account-change notifications, nothing else. Kelpie never sends outreach
|
|
8
10
|
* email.
|
|
9
11
|
*
|
|
10
|
-
* Roadmap decision 4: the provider is configured, never hardcoded. Core ships
|
|
11
|
-
* port and
|
|
12
|
-
*
|
|
12
|
+
* Roadmap decision 4: the provider is configured, never hardcoded. Core ships
|
|
13
|
+
* the port and two providers, `log` and `smtp`. Both need no vendor account a
|
|
14
|
+
* deployment doesn't already have, which is what keeps them in core rather than
|
|
15
|
+
* a module, per `modules.md`'s split test. A provider that does need one
|
|
16
|
+
* (Resend, Postmark, Mailtrap, SendGrid) is a commercial integration under that
|
|
17
|
+
* same test and belongs in a module: it supplies its own `EmailSender` from its
|
|
18
|
+
* own config, and an assembly's entry point wires it into `services.email` in
|
|
19
|
+
* place of `createEmailSender`, the same way `kelpie-cloud/src/server.ts` wires
|
|
20
|
+
* this one today.
|
|
13
21
|
*/
|
|
14
22
|
|
|
15
23
|
export interface EmailMessage {
|
|
@@ -22,12 +30,28 @@ export interface EmailSender {
|
|
|
22
30
|
send(message: EmailMessage): Promise<void>
|
|
23
31
|
}
|
|
24
32
|
|
|
25
|
-
|
|
26
|
-
EMAIL_PROVIDER: z.
|
|
33
|
+
const logEmailConfigSchema = z.object({
|
|
34
|
+
EMAIL_PROVIDER: z.literal('log'),
|
|
27
35
|
EMAIL_FROM: z.string().min(1),
|
|
28
36
|
})
|
|
29
37
|
|
|
38
|
+
const smtpEmailConfigSchema = z.object({
|
|
39
|
+
EMAIL_PROVIDER: z.literal('smtp'),
|
|
40
|
+
EMAIL_FROM: z.string().min(1),
|
|
41
|
+
SMTP_HOST: z.string().min(1),
|
|
42
|
+
SMTP_PORT: z.coerce.number().int().positive().max(65535),
|
|
43
|
+
SMTP_SECURE: z.enum(['true', 'false']).transform((value) => value === 'true'),
|
|
44
|
+
SMTP_USER: z.string().min(1),
|
|
45
|
+
SMTP_PASSWORD: z.string().min(1),
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
export const emailConfigSchema = z.discriminatedUnion('EMAIL_PROVIDER', [
|
|
49
|
+
logEmailConfigSchema,
|
|
50
|
+
smtpEmailConfigSchema,
|
|
51
|
+
])
|
|
52
|
+
|
|
30
53
|
export type EmailConfig = z.infer<typeof emailConfigSchema>
|
|
54
|
+
export type SmtpEmailConfig = z.infer<typeof smtpEmailConfigSchema>
|
|
31
55
|
|
|
32
56
|
/**
|
|
33
57
|
* Writes the message to the log instead of sending it. For self-hosted
|
|
@@ -51,15 +75,59 @@ export function createLogEmailSender(logger: Logger, from: string): EmailSender
|
|
|
51
75
|
}
|
|
52
76
|
}
|
|
53
77
|
|
|
78
|
+
/**
|
|
79
|
+
* What `createSmtpEmailSender` sends a message through. A real deployment gets
|
|
80
|
+
* one backed by `nodemailer`; a test injects one that records calls, so the
|
|
81
|
+
* sender is verifiable without a container running a real SMTP conversation.
|
|
82
|
+
*/
|
|
83
|
+
export interface SmtpTransport {
|
|
84
|
+
sendMail(message: { from: string; to: string; subject: string; text: string }): Promise<unknown>
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function createNodemailerTransport(config: SmtpEmailConfig): SmtpTransport {
|
|
88
|
+
return nodemailer.createTransport({
|
|
89
|
+
host: config.SMTP_HOST,
|
|
90
|
+
port: config.SMTP_PORT,
|
|
91
|
+
secure: config.SMTP_SECURE,
|
|
92
|
+
auth: { user: config.SMTP_USER, pass: config.SMTP_PASSWORD },
|
|
93
|
+
})
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Sends over SMTP. `transport` defaults to a real connection built from `config`. */
|
|
97
|
+
export function createSmtpEmailSender(
|
|
98
|
+
config: SmtpEmailConfig,
|
|
99
|
+
logger: Logger,
|
|
100
|
+
transport: SmtpTransport = createNodemailerTransport(config),
|
|
101
|
+
): EmailSender {
|
|
102
|
+
return {
|
|
103
|
+
async send(message) {
|
|
104
|
+
try {
|
|
105
|
+
await transport.sendMail({
|
|
106
|
+
from: config.EMAIL_FROM,
|
|
107
|
+
to: message.to,
|
|
108
|
+
subject: message.subject,
|
|
109
|
+
text: message.body,
|
|
110
|
+
})
|
|
111
|
+
} catch (cause) {
|
|
112
|
+
const reason = describeThrown(cause)
|
|
113
|
+
logger.error('smtp send failed', { to: message.to, reason })
|
|
114
|
+
throw new Error(`Failed to send email to ${message.to} over SMTP: ${reason}`, { cause })
|
|
115
|
+
}
|
|
116
|
+
},
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
54
120
|
/**
|
|
55
121
|
* Builds the configured sender.
|
|
56
122
|
*
|
|
57
|
-
* @throws Never. An unknown provider cannot reach here: the config schema is
|
|
58
|
-
*
|
|
123
|
+
* @throws Never. An unknown provider cannot reach here: the config schema is a
|
|
124
|
+
* discriminated union, so boot rejects it first.
|
|
59
125
|
*/
|
|
60
126
|
export function createEmailSender(config: EmailConfig, logger: Logger): EmailSender {
|
|
61
127
|
switch (config.EMAIL_PROVIDER) {
|
|
62
128
|
case 'log':
|
|
63
129
|
return createLogEmailSender(logger, config.EMAIL_FROM)
|
|
130
|
+
case 'smtp':
|
|
131
|
+
return createSmtpEmailSender(config, logger)
|
|
64
132
|
}
|
|
65
133
|
}
|