@serve.zone/platformclient 1.1.4 → 1.2.0

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.
@@ -3,6 +3,80 @@ import * as plugins from '../plugins.js';
3
3
 
4
4
  import { logger } from '../logger.js';
5
5
 
6
+ export type TServiceMailTlsMode = 'plain' | 'starttls' | 'implicitTls';
7
+
8
+ export interface IServiceMailSmtpCredentials {
9
+ from: string;
10
+ host: string;
11
+ port: number;
12
+ tlsMode: TServiceMailTlsMode;
13
+ username: string;
14
+ password: string;
15
+ addressToken?: string;
16
+ }
17
+
18
+ export interface IServiceMailStatus {
19
+ ready: boolean;
20
+ from?: string;
21
+ host?: string;
22
+ port?: number;
23
+ tlsMode?: TServiceMailTlsMode;
24
+ usernameConfigured: boolean;
25
+ passwordConfigured: boolean;
26
+ missing: string[];
27
+ }
28
+
29
+ export interface IServiceMailAttachment {
30
+ filename: string;
31
+ contentType?: string;
32
+ content: string | Uint8Array;
33
+ }
34
+
35
+ export interface IServiceMailSendOptions {
36
+ from?: string;
37
+ to: string | string[];
38
+ cc?: string | string[];
39
+ bcc?: string | string[];
40
+ subject: string;
41
+ text?: string;
42
+ html?: string;
43
+ headers?: Record<string, string>;
44
+ attachments?: IServiceMailAttachment[];
45
+ }
46
+
47
+ export interface IServiceMailSendResult {
48
+ messageId?: string;
49
+ accepted?: string[];
50
+ rejected?: string[];
51
+ response?: string;
52
+ }
53
+
54
+ export interface IServiceMailInboundMessage extends plugins.servezoneInterfaces.data.IMailInboundMessagePayload {
55
+ from: string;
56
+ to: string[];
57
+ receivedAtIso: string;
58
+ }
59
+
60
+ export interface IServiceMailInboundHandlerResult {
61
+ accepted?: boolean;
62
+ workAppMessageId?: string;
63
+ message?: string;
64
+ }
65
+
66
+ export type IServiceMailInboundHandleResult =
67
+ plugins.servezoneInterfaces.requests.mail.IReq_DeliverInboundMail['response'];
68
+
69
+ interface INodemailerSendResult {
70
+ messageId?: string;
71
+ accepted?: unknown[];
72
+ rejected?: unknown[];
73
+ response?: string;
74
+ }
75
+
76
+ export type TServiceMailInboundHandler = (
77
+ messageArg: IServiceMailInboundMessage,
78
+ ) => Promise<IServiceMailInboundHandlerResult | void> | IServiceMailInboundHandlerResult | void;
79
+
6
80
  export class SzEmailConnector {
7
81
  public platformClientRef: SzPlatformClient;
8
82
 
@@ -10,6 +84,9 @@ export class SzEmailConnector {
10
84
  this.platformClientRef = platformClientRefArg;
11
85
  }
12
86
 
87
+ /**
88
+ * Legacy platform-service email path. New service-mail code should use sendMail().
89
+ */
13
90
  public async sendEmail(
14
91
  optionsArg: plugins.servezoneInterfaces.platform.email.IReq_SendEmail['request']
15
92
  ) {
@@ -30,10 +107,255 @@ export class SzEmailConnector {
30
107
  'sendEmail'
31
108
  );
32
109
  const response = await typedRequest.fire(optionsArg);
110
+ return response.responseId;
111
+ }
112
+
113
+ public async getServiceMailStatus(addressArg?: string): Promise<IServiceMailStatus> {
114
+ const requestedAddress = addressArg?.trim().toLowerCase();
115
+ const token = requestedAddress ? this.getMailEnvToken(requestedAddress) : undefined;
116
+ const scopedEnv = token ? await this.readMailEnv(token) : undefined;
117
+ const defaultEnv = token ? await this.readMailEnv() : undefined;
118
+ const useDefaultEnv = requestedAddress
119
+ && !this.hasAnyMailEnvValue(scopedEnv)
120
+ && defaultEnv?.from?.trim().toLowerCase() === requestedAddress;
121
+ const env = useDefaultEnv ? defaultEnv! : scopedEnv || await this.readMailEnv();
122
+ const missingPrefix = useDefaultEnv || !token ? undefined : token;
123
+ const port = env.smtpPort ? Number(env.smtpPort) : undefined;
124
+ const tlsMode = this.normalizeTlsMode(env.smtpTlsMode || 'starttls');
125
+ const missing: string[] = [];
126
+ if (!env.from) missing.push(missingPrefix ? `MAIL_${missingPrefix}_FROM` : 'MAIL_FROM');
127
+ if (!env.smtpHost) missing.push(missingPrefix ? `MAIL_${missingPrefix}_SMTP_HOST` : 'SMTP_HOST');
128
+ if (!env.smtpPort || !Number.isInteger(port) || port! < 1 || port! > 65535) {
129
+ missing.push(missingPrefix ? `MAIL_${missingPrefix}_SMTP_PORT` : 'SMTP_PORT');
130
+ }
131
+ if (!env.smtpUsername) missing.push(missingPrefix ? `MAIL_${missingPrefix}_SMTP_USERNAME` : 'SMTP_USERNAME');
132
+ if (!env.smtpPassword) missing.push(missingPrefix ? `MAIL_${missingPrefix}_SMTP_PASSWORD` : 'SMTP_PASSWORD');
133
+ return {
134
+ ready: missing.length === 0,
135
+ from: env.from,
136
+ host: env.smtpHost,
137
+ port,
138
+ tlsMode,
139
+ usernameConfigured: Boolean(env.smtpUsername),
140
+ passwordConfigured: Boolean(env.smtpPassword),
141
+ missing,
142
+ };
143
+ }
144
+
145
+ public async getServiceMailCredentials(addressArg?: string): Promise<IServiceMailSmtpCredentials> {
146
+ const requestedAddress = addressArg?.trim().toLowerCase();
147
+ const requestedToken = requestedAddress ? this.getMailEnvToken(requestedAddress) : undefined;
148
+ const scopedEnv = requestedToken ? await this.readMailEnv(requestedToken) : undefined;
149
+ const hasScopedConfig = scopedEnv && Object.values(scopedEnv).some(Boolean);
150
+ const env = hasScopedConfig ? scopedEnv! : await this.readMailEnv();
151
+ const from = env.from?.trim().toLowerCase();
152
+ if (!from) throw new Error('MAIL_FROM is required for service mail');
153
+ if (requestedAddress && from !== requestedAddress) {
154
+ throw new Error(`No service mail credentials are configured for ${requestedAddress}`);
155
+ }
156
+ const port = Number(env.smtpPort);
157
+ if (!env.smtpHost) throw new Error('SMTP_HOST is required for service mail');
158
+ if (!Number.isInteger(port) || port < 1 || port > 65535) {
159
+ throw new Error('SMTP_PORT must be an integer between 1 and 65535');
160
+ }
161
+ if (!env.smtpUsername) throw new Error('SMTP_USERNAME is required for service mail');
162
+ if (!env.smtpPassword) throw new Error('SMTP_PASSWORD is required for service mail');
163
+ return {
164
+ from,
165
+ host: env.smtpHost,
166
+ port,
167
+ tlsMode: this.normalizeTlsMode(env.smtpTlsMode || 'starttls'),
168
+ username: env.smtpUsername,
169
+ password: env.smtpPassword,
170
+ addressToken: hasScopedConfig ? requestedToken : undefined,
171
+ };
172
+ }
173
+
174
+ public async sendText(optionsArg: Omit<IServiceMailSendOptions, 'html'> & { text: string }) {
175
+ return await this.sendMail(optionsArg);
176
+ }
177
+
178
+ public async sendHtml(optionsArg: Omit<IServiceMailSendOptions, 'text'> & { html: string }) {
179
+ return await this.sendMail(optionsArg);
180
+ }
181
+
182
+ public async sendMail(optionsArg: IServiceMailSendOptions): Promise<IServiceMailSendResult> {
183
+ if (!optionsArg.text && !optionsArg.html) {
184
+ throw new Error('Either text or html is required for service mail');
185
+ }
186
+ if (this.platformClientRef.debugMode) {
187
+ logger.log('info', `service mail debug send: ${optionsArg.subject} to ${this.listToArray(optionsArg.to).join(', ')}`);
188
+ return { messageId: 'debug' };
189
+ }
33
190
 
191
+ const credentials = await this.getServiceMailCredentials(optionsArg.from);
192
+ const smartsmtp = await plugins.getSmartsmtp();
193
+ const smtp = await smartsmtp.Smartsmtp.createSmartsmtpWithRelay({
194
+ smtpServer: credentials.host,
195
+ smtpPort: credentials.port,
196
+ smtpTlsMode: credentials.tlsMode,
197
+ smtpUser: credentials.username,
198
+ smtpPassword: credentials.password,
199
+ });
200
+ const result = (await smtp.nodemailerTransport.sendMail({
201
+ from: credentials.from,
202
+ to: this.listToArray(optionsArg.to),
203
+ cc: optionsArg.cc ? this.listToArray(optionsArg.cc) : undefined,
204
+ bcc: optionsArg.bcc ? this.listToArray(optionsArg.bcc) : undefined,
205
+ subject: optionsArg.subject,
206
+ text: optionsArg.text,
207
+ html: optionsArg.html,
208
+ headers: optionsArg.headers,
209
+ attachments: optionsArg.attachments?.map((attachmentArg) => ({
210
+ filename: attachmentArg.filename,
211
+ contentType: attachmentArg.contentType,
212
+ content: this.normalizeAttachmentContent(attachmentArg.content),
213
+ })),
214
+ })) as INodemailerSendResult;
215
+ return {
216
+ messageId: result.messageId,
217
+ accepted: Array.isArray(result.accepted) ? result.accepted.map(String) : undefined,
218
+ rejected: Array.isArray(result.rejected) ? result.rejected.map(String) : undefined,
219
+ response: result.response,
220
+ };
221
+ }
222
+
223
+ public normalizeInboundMessage(payloadArg: unknown): IServiceMailInboundMessage {
224
+ const payload = this.asRecord(payloadArg);
225
+ const deliveryCandidate = this.asRecord(payload.delivery || payload.message || payload);
226
+ const envelope = this.asRecord(deliveryCandidate.envelope);
227
+ const rawMessage = typeof deliveryCandidate.rawMessage === 'string' ? deliveryCandidate.rawMessage : '';
228
+ const rcptTo = Array.isArray(envelope.rcptTo) ? envelope.rcptTo.map(String) : [];
229
+ const receivedAt = typeof deliveryCandidate.receivedAt === 'number' ? deliveryCandidate.receivedAt : Date.now();
230
+ return {
231
+ spoolItemId: String(deliveryCandidate.spoolItemId || deliveryCandidate.id || ''),
232
+ owner: deliveryCandidate.owner as plugins.servezoneInterfaces.data.IMailResourceOwner | undefined,
233
+ envelope: {
234
+ mailFrom: String(envelope.mailFrom || deliveryCandidate.from || ''),
235
+ rcptTo,
236
+ },
237
+ rawMessage,
238
+ sizeBytes: typeof deliveryCandidate.sizeBytes === 'number' ? deliveryCandidate.sizeBytes : rawMessage.length,
239
+ messageId: deliveryCandidate.messageId ? String(deliveryCandidate.messageId) : undefined,
240
+ subject: deliveryCandidate.subject ? String(deliveryCandidate.subject) : this.extractHeader(rawMessage, 'subject'),
241
+ headers: this.normalizeHeaders(deliveryCandidate.headers),
242
+ source: deliveryCandidate.source as plugins.servezoneInterfaces.data.IMailConnectionInfo | undefined,
243
+ receivedAt,
244
+ from: String(envelope.mailFrom || deliveryCandidate.from || ''),
245
+ to: rcptTo,
246
+ receivedAtIso: new Date(receivedAt).toISOString(),
247
+ };
248
+ }
249
+
250
+ public async handleInboundPayload(
251
+ payloadArg: unknown,
252
+ handlerArg: TServiceMailInboundHandler,
253
+ ): Promise<IServiceMailInboundHandleResult> {
254
+ const message = this.normalizeInboundMessage(payloadArg);
255
+ const result = await handlerArg(message);
256
+ return {
257
+ accepted: result?.accepted !== false,
258
+ workAppMessageId: result?.workAppMessageId,
259
+ message: result?.message,
260
+ };
261
+ }
262
+
263
+ public createInboundHandler(handlerArg: TServiceMailInboundHandler) {
264
+ return async (payloadArg: unknown) => await this.handleInboundPayload(payloadArg, handlerArg);
34
265
  }
35
266
 
36
267
  public async sendNotification(optionsArg: { toArg: string; subject: string; text: string }) {}
37
268
 
38
269
  public async sendActionLink(optionsArg: { toArg: string; subject: string; text: string }) {}
270
+
271
+ private async readMailEnv(tokenArg?: string): Promise<{
272
+ from?: string;
273
+ smtpHost?: string;
274
+ smtpPort?: string;
275
+ smtpTlsMode?: string;
276
+ smtpUsername?: string;
277
+ smtpPassword?: string;
278
+ }> {
279
+ const prefix = tokenArg ? `MAIL_${tokenArg}_` : '';
280
+ return {
281
+ from: await this.readMailEnvVar(tokenArg ? `${prefix}FROM` : 'MAIL_FROM'),
282
+ smtpHost: await this.readMailEnvVar(`${prefix}SMTP_HOST`),
283
+ smtpPort: await this.readMailEnvVar(`${prefix}SMTP_PORT`),
284
+ smtpTlsMode: await this.readMailEnvVar(`${prefix}SMTP_TLS_MODE`),
285
+ smtpUsername: await this.readMailEnvVar(`${prefix}SMTP_USERNAME`),
286
+ smtpPassword: await this.readMailEnvVar(`${prefix}SMTP_PASSWORD`),
287
+ };
288
+ }
289
+
290
+ private async readMailEnvVar(envNameArg: string): Promise<string | undefined> {
291
+ return await this.platformClientRef.getConfiguredEnvVar(envNameArg)
292
+ || this.getCredentialEnvVarFromBindings(envNameArg);
293
+ }
294
+
295
+ private getCredentialEnvVarFromBindings(envNameArg: string): string | undefined {
296
+ for (const binding of this.platformClientRef.platformBindings || []) {
297
+ if (binding.desiredState === 'disabled' || binding.status === 'failed') continue;
298
+ if (binding.capability !== 'email') continue;
299
+ for (const credential of binding.credentials || []) {
300
+ const value = credential.env?.[envNameArg];
301
+ if (value) return value;
302
+ }
303
+ }
304
+ }
305
+
306
+ private hasAnyMailEnvValue(envArg?: {
307
+ from?: string;
308
+ smtpHost?: string;
309
+ smtpPort?: string;
310
+ smtpTlsMode?: string;
311
+ smtpUsername?: string;
312
+ smtpPassword?: string;
313
+ }): boolean {
314
+ return Boolean(envArg && Object.values(envArg).some(Boolean));
315
+ }
316
+
317
+ private normalizeTlsMode(valueArg: string): TServiceMailTlsMode {
318
+ if (valueArg === 'plain' || valueArg === 'starttls' || valueArg === 'implicitTls') return valueArg;
319
+ throw new Error(`Invalid SMTP_TLS_MODE: ${valueArg}`);
320
+ }
321
+
322
+ private getMailEnvToken(addressArg: string): string {
323
+ const token = addressArg.trim().toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, '').toUpperCase();
324
+ return /^\d/.test(token) ? `ADDR_${token}` : token;
325
+ }
326
+
327
+ private listToArray(valueArg: string | string[]): string[] {
328
+ return Array.isArray(valueArg) ? valueArg : [valueArg];
329
+ }
330
+
331
+ private normalizeAttachmentContent(contentArg: string | Uint8Array): string | Buffer {
332
+ return typeof contentArg === 'string' ? contentArg : Buffer.from(contentArg);
333
+ }
334
+
335
+ private asRecord(valueArg: unknown): Record<string, unknown> {
336
+ return valueArg && typeof valueArg === 'object' ? valueArg as Record<string, unknown> : {};
337
+ }
338
+
339
+ private normalizeHeaders(headersArg: unknown): Record<string, string | string[]> | undefined {
340
+ if (!headersArg || typeof headersArg !== 'object') return undefined;
341
+ const result: Record<string, string | string[]> = {};
342
+ for (const [key, value] of Object.entries(headersArg as Record<string, unknown>)) {
343
+ if (Array.isArray(value)) {
344
+ result[key] = value.map(String);
345
+ } else if (value !== undefined && value !== null) {
346
+ result[key] = String(value);
347
+ }
348
+ }
349
+ return result;
350
+ }
351
+
352
+ private extractHeader(rawMessageArg: string, headerNameArg: string): string | undefined {
353
+ const lowerName = `${headerNameArg.toLowerCase()}:`;
354
+ for (const line of rawMessageArg.split(/\r?\n/)) {
355
+ if (!line) return undefined;
356
+ if (line.toLowerCase().startsWith(lowerName)) {
357
+ return line.slice(lowerName.length).trim();
358
+ }
359
+ }
360
+ }
39
361
  }
@@ -25,5 +25,6 @@ export class SzLetterConnector {
25
25
  'sendLetter'
26
26
  );
27
27
  const response = await typedRequest.fire(optionsArg);
28
+ return response.processId;
28
29
  }
29
30
  }
@@ -25,7 +25,7 @@ export class SzPushNotificationConnector {
25
25
  );
26
26
  const response = await typedRequest.fire(optionsArg);
27
27
 
28
- if (process.env.SERVEZONE_PLATFORMCLIENT_DEBUG) {
28
+ if (typeof process !== 'undefined' && process.env?.SERVEZONE_PLATFORMCLIENT_DEBUG) {
29
29
  console.log('sendPushNotification response', response);
30
30
  }
31
31
 
package/ts/index.ts CHANGED
@@ -1 +1,2 @@
1
1
  export * from './classes.platformclient.js';
2
+ export * from './email/classes.emailconnector.js';
package/ts/plugins.ts CHANGED
@@ -14,6 +14,8 @@ export {
14
14
  smartlog,
15
15
  }
16
16
 
17
+ export const getSmartsmtp = async () => await import('@push.rocks/smartsmtp');
18
+
17
19
 
18
20
  // @api.global scope
19
21
  import * as typedrequest from '@api.global/typedrequest';