thormail-adapter-generic-rest 1.0.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.
Files changed (3) hide show
  1. package/README.md +80 -0
  2. package/index.js +330 -0
  3. package/package.json +19 -0
package/README.md ADDED
@@ -0,0 +1,80 @@
1
+ # ThorMail Generic REST API Adapter
2
+
3
+ A highly configurable adapter for [ThorMail](https://thormail.io) that allows sending messages to any generic REST API.
4
+
5
+ ## 📖 Overview
6
+
7
+ This adapter provides a flexible bridge between ThorMail and any external service that accepts HTTP requests (POST, PUT, PATCH, GET). It is ideal for:
8
+
9
+ - Integrating with bespoke internal APIs.
10
+ - Connecting to small providers without dedicated adapters.
11
+ - Prototyping new integrations quickly.
12
+ - Webhook-based messaging.
13
+
14
+ ## 🛠️ Configuration
15
+
16
+ When creating a new Connection in ThorMail, select **Generic REST API** and configure the following fields:
17
+
18
+ | Field | Type | Description |
19
+ |-------|------|-------------|
20
+ | **Base URL** | `text` | The full HTTP(S) endpoint of the external API. |
21
+ | **HTTP Method**| `select` | The method to use (POST, PUT, PATCH, GET). |
22
+ | **Custom Headers** | `customHeaders` | Headers required for authentication or metadata (e.g., `Authorization`). |
23
+ | **Payload Template** | `json-template` | A JSON template with `{{to}}`, `{{subject}}`, and `{{body}}` placeholders. |
24
+ | **Success Status Code(s)** | `text` | Comma-separated list of successful HTTP codes (default: `200,201,202`). |
25
+
26
+ ### Payload Replacements
27
+
28
+ You can use the following variables in your **Payload Template**:
29
+
30
+ - `{{to}}`: The recipient's identifier (address, phone, ID).
31
+ - `{{subject}}`: The message subject or title.
32
+ - `{{body}}`: The main content of the message.
33
+ - `{{any_data_key}}`: Any key passed in the `data` object during the send request.
34
+
35
+ ### Example Configuration: Simple Webhook
36
+
37
+ - **Base URL**: `https://hooks.example.com/services/T0000/B0000/XXXX`
38
+ - **Method**: `POST`
39
+ - **Payload Template**:
40
+
41
+ ```json
42
+ {
43
+ "text": "New Mail to {{to}}: {{subject}}",
44
+ "details": "{{body}}"
45
+ }
46
+ ```
47
+
48
+ ### Example Configuration: authenticated API
49
+
50
+ - **Base URL**: `https://api.provider.com/v1/messages`
51
+ - **Method**: `POST`
52
+ - **Custom Headers**:
53
+ - `X-API-Key`: `your-secret-key`
54
+ - `Content-Type`: `application/json`
55
+ - **Payload Template**:
56
+
57
+ ```json
58
+ {
59
+ "recipient": "{{to}}",
60
+ "title": "{{subject}}",
61
+ "html_body": "{{body}}",
62
+ "metadata": {
63
+ "source": "thormail"
64
+ }
65
+ }
66
+ ```
67
+
68
+ ## 🚀 Features
69
+
70
+ - **Standard Idempotency**: Automatically sends `Idempotency-Key` or `X-Idempotency-Key` headers if available.
71
+ - **Smart Retries**: Correctly identifies 429 and 5xx errors as temporary for ThorMail's retry logic.
72
+ - **Custom Success Logic**: Configurable success status codes to match legacy or non-standard APIs.
73
+ - **Dynamic Headers**: Merges headers provided in the specific send request with the global configuration.
74
+
75
+ ## 📜 License
76
+
77
+ This adapter is licensed under the MIT License. See the main ThorMail repository for details.
78
+
79
+ ---
80
+ *Powered by [ThorMail](https://thormail.io) - The God of Deliveries.*
package/index.js ADDED
@@ -0,0 +1,330 @@
1
+ /**
2
+ * Generic REST API Adapter for ThorMail
3
+ * Allows sending messages via any RESTful API with configurable endpoints and payloads.
4
+ */
5
+ export default class GenericRestAdapter {
6
+ /**
7
+ * Defines the configuration form schema for the frontend.
8
+ */
9
+ static getConfigSchema() {
10
+ return [
11
+ {
12
+ name: 'custom_name',
13
+ label: 'Name',
14
+ type: 'text',
15
+ required: false,
16
+ placeholder: 'My Generic API',
17
+ hint: 'Internal name for this adapter instance.',
18
+ group: 'settings'
19
+ },
20
+ {
21
+ name: 'baseUrl',
22
+ label: 'Base URL',
23
+ type: 'text',
24
+ required: true,
25
+ placeholder: 'https://api.myservice.com/v1/send',
26
+ hint: 'The full URL endpoint where the request will be sent.',
27
+ group: 'connection'
28
+ },
29
+ {
30
+ name: 'method',
31
+ label: 'HTTP Method',
32
+ type: 'select',
33
+ required: true,
34
+ options: ['POST', 'PUT', 'PATCH', 'GET'],
35
+ placeholder: 'POST',
36
+ hint: 'The HTTP method to use for sending the message.',
37
+ group: 'connection'
38
+ },
39
+ {
40
+ name: 'customHeaders',
41
+ label: 'Custom Headers',
42
+ type: 'customHeaders',
43
+ required: false,
44
+ hint: 'Optional HTTP headers to include in the request (e.g., Authorization, X-API-Key).',
45
+ group: 'authentication'
46
+ },
47
+ {
48
+ name: 'payloadTemplate',
49
+ label: 'Payload Template',
50
+ type: 'json-template',
51
+ required: false,
52
+ placeholder: '{\n "recipient": "{{to}}",\n "content": "{{body}}"\n}',
53
+ hint: 'A JSON template for the request body. Values like {{to}}, {{subject}}, and {{body}} will be automatically replaced.',
54
+ group: 'payload'
55
+ },
56
+ {
57
+ name: 'successStatus',
58
+ label: 'Success Status Code(s)',
59
+ type: 'text',
60
+ required: false,
61
+ placeholder: '200,201,202',
62
+ hint: 'Comma-separated list of HTTP status codes to treat as success. Defaults to 200, 201, 202.',
63
+ group: 'validation'
64
+ },
65
+ {
66
+ name: 'from_email',
67
+ label: 'From Email',
68
+ type: 'text',
69
+ required: false,
70
+ placeholder: 'noreply@yourdomain.com',
71
+ hint: 'Default sender email address if required by the destination API.',
72
+ group: 'defaults'
73
+ },
74
+ {
75
+ name: 'from_name',
76
+ label: 'From Name',
77
+ type: 'text',
78
+ required: false,
79
+ placeholder: 'My App',
80
+ hint: 'Default sender name.',
81
+ group: 'defaults'
82
+ }
83
+ ];
84
+ }
85
+
86
+ /**
87
+ * Defines adapter metadata for the registry.
88
+ */
89
+ static getMetadata() {
90
+ return {
91
+ name: 'Generic REST API',
92
+ description: 'Send messages via any generic REST API with custom payloads and headers.',
93
+ group: 'generic'
94
+ };
95
+ }
96
+
97
+ /**
98
+ * @param {Object} config - The configuration object.
99
+ */
100
+ constructor(config) {
101
+ this.config = config;
102
+ this.successCodes = (config.successStatus || '200,201,202')
103
+ .split(',')
104
+ .map(code => parseInt(code.trim(), 10));
105
+ }
106
+
107
+ /**
108
+ * Internal helper to make HTTP requests.
109
+ */
110
+ async _request(method, url, body = null, headers = {}) {
111
+ const fetchHeaders = { ...headers };
112
+
113
+ // Default Content-Type if body is provided and not already set
114
+ if (body && !Object.keys(fetchHeaders).some(h => h.toLowerCase() === 'content-type')) {
115
+ fetchHeaders['Content-Type'] = 'application/json';
116
+ }
117
+
118
+ const fetchOptions = {
119
+ method,
120
+ headers: fetchHeaders
121
+ };
122
+
123
+ if (body && method !== 'GET' && method !== 'HEAD') {
124
+ fetchOptions.body = typeof body === 'string' ? body : JSON.stringify(body);
125
+ }
126
+
127
+ try {
128
+ const response = await fetch(url, fetchOptions);
129
+ const status = response.status;
130
+ let responseData;
131
+ const text = await response.text();
132
+
133
+ try {
134
+ responseData = text ? JSON.parse(text) : {};
135
+ } catch (e) {
136
+ responseData = { raw: text };
137
+ }
138
+
139
+ const success = this.successCodes.includes(status);
140
+
141
+ if (!success) {
142
+ // Determine if temporary error
143
+ // 429 (Rate Limit) and 5xx (Server Error) are usually temporary
144
+ const isTemporary = status === 429 || (status >= 500 && status < 600);
145
+
146
+ return {
147
+ success: false,
148
+ error: `HTTP ${status}: ${text.substring(0, 500)}`,
149
+ isTemporary,
150
+ status,
151
+ response: responseData
152
+ };
153
+ }
154
+
155
+ return { success: true, data: responseData, status };
156
+ } catch (error) {
157
+ return {
158
+ success: false,
159
+ error: `Network/Fetch Error: ${error.message}`,
160
+ isTemporary: true
161
+ };
162
+ }
163
+ }
164
+
165
+ /**
166
+ * Validates configuration by attempting a request to the base URL.
167
+ */
168
+ async validateConfig() {
169
+ try {
170
+ // For validation, we use the method configured, but if it's POST/PUT/PATCH we don't send a body.
171
+ // Some APIs might reject this, so we try a simple request.
172
+ const validationHeaders = this._parseHeaders(this.config.customHeaders);
173
+
174
+ // If the user provided a success status, we check if the base URL responds with one of them.
175
+ // Note: This is an optimistic check.
176
+ const result = await this._request(this.config.method || 'GET', this.config.baseUrl, null, validationHeaders);
177
+
178
+ if (result.success) {
179
+ return {
180
+ success: true,
181
+ message: `Successfully reached the endpoint (Status ${result.status}).`,
182
+ canValidate: true
183
+ };
184
+ } else {
185
+ return {
186
+ success: false,
187
+ message: `Endpoint returned error: ${result.error}`,
188
+ canValidate: true
189
+ };
190
+ }
191
+ } catch (error) {
192
+ return {
193
+ success: false,
194
+ message: `Validation failed: ${error.message}`,
195
+ canValidate: true
196
+ };
197
+ }
198
+ }
199
+
200
+ /**
201
+ * Checks if the external service is reachable.
202
+ */
203
+ async healthCheck() {
204
+ const result = await this.validateConfig();
205
+ return result.success ? 'HEALTHY' : 'UNHEALTHY';
206
+ }
207
+
208
+ /**
209
+ * Dispatches a message via the configured REST API.
210
+ * @param {Object} params - { to, subject, body, data, idempotencyKey }
211
+ */
212
+ async sendMail({ to, subject, body, data, idempotencyKey }) {
213
+ try {
214
+ const method = this.config.method || 'POST';
215
+ const url = this.config.baseUrl;
216
+ const headers = this._parseHeaders(this.config.customHeaders);
217
+
218
+ // Merge headers from the send request if any
219
+ if (data && data.headers) {
220
+ Object.assign(headers, data.headers);
221
+ }
222
+
223
+ // Handle Idempotency Key
224
+ if (idempotencyKey) {
225
+ // Common headers for idempotency, can be overridden by customHeaders
226
+ const idHeaders = ['Idempotency-Key', 'X-Idempotency-Key', 'X-Request-Id'];
227
+ for (const h of idHeaders) {
228
+ if (!Object.keys(headers).some(key => key.toLowerCase() === h.toLowerCase())) {
229
+ headers[h] = idempotencyKey;
230
+ }
231
+ }
232
+ }
233
+
234
+ let payload;
235
+
236
+ if (this.config.payloadTemplate) {
237
+ // If a template is provided, we replace variables.
238
+ // ThorMail core usually provides data with replacements,
239
+ // but we also have to, subject, body specifically.
240
+ let templateStr = this.config.payloadTemplate;
241
+ const replacements = {
242
+ ...data,
243
+ to,
244
+ subject,
245
+ body,
246
+ from_email: this.config.from_email || (data && data.from_email),
247
+ from_name: this.config.from_name || (data && data.from_name)
248
+ };
249
+
250
+ // Simple mustache-like replacement: {{variable}}
251
+ payload = templateStr.replace(/\{\{([^}]+)\}\}/g, (match, p1) => {
252
+ const key = p1.trim();
253
+ return replacements[key] !== undefined ? replacements[key] : match;
254
+ });
255
+
256
+ // Attempt to parse as JSON if it looks like one
257
+ try {
258
+ payload = JSON.parse(payload);
259
+ } catch (e) {
260
+ // Keep as string if not valid JSON
261
+ }
262
+ } else {
263
+ // Default payload if no template is provided
264
+ payload = {
265
+ to,
266
+ subject,
267
+ content: body,
268
+ data
269
+ };
270
+ }
271
+
272
+ const result = await this._request(method, url, payload, headers);
273
+
274
+ if (!result.success) {
275
+ return result;
276
+ }
277
+
278
+ // Extract ID if possible from response
279
+ const responseId = result.data?.id || result.data?.messageId || result.data?.result?.id || `rest-${Date.now()}`;
280
+
281
+ return {
282
+ success: true,
283
+ id: String(responseId),
284
+ response: result.data,
285
+ isTemporary: false
286
+ };
287
+ } catch (error) {
288
+ return {
289
+ success: false,
290
+ error: `Unexpected Error: ${error.message}`,
291
+ isTemporary: true
292
+ };
293
+ }
294
+ }
295
+
296
+ /**
297
+ * Processes incoming webhooks (optional for generic REST, as it depends on the destination service).
298
+ */
299
+ async webhook(event, headers) {
300
+ // Generic adapter cannot know the format of webhooks from unknown services.
301
+ // User should use a specialized adapter or the core's generic webhook handling if available.
302
+ return null;
303
+ }
304
+
305
+ /**
306
+ * Helper to parse customHeaders field.
307
+ * ThorMail customHeaders type usually comes as an array of objects { key, value } or a JSON object.
308
+ */
309
+ _parseHeaders(headersConfig) {
310
+ const headers = {};
311
+ if (!headersConfig) return headers;
312
+
313
+ if (Array.isArray(headersConfig)) {
314
+ headersConfig.forEach(h => {
315
+ if (h.key && h.value) {
316
+ headers[h.key] = h.value;
317
+ }
318
+ });
319
+ } else if (typeof headersConfig === 'object') {
320
+ Object.assign(headers, headersConfig);
321
+ } else if (typeof headersConfig === 'string') {
322
+ try {
323
+ Object.assign(headers, JSON.parse(headersConfig));
324
+ } catch (e) {
325
+ // Ignore invalid JSON
326
+ }
327
+ }
328
+ return headers;
329
+ }
330
+ }
package/package.json ADDED
@@ -0,0 +1,19 @@
1
+ {
2
+ "name": "thormail-adapter-generic-rest",
3
+ "version": "1.0.1",
4
+ "description": "Standard REST API adapter for ThorMail. Send messages via any RESTful service with configurable URL, method, and headers.",
5
+ "main": "index.js",
6
+ "type": "module",
7
+ "keywords": [
8
+ "thormail",
9
+ "thormail-adapter",
10
+ "rest",
11
+ "api",
12
+ "generic"
13
+ ],
14
+ "author": "ThorMail Team",
15
+ "license": "MIT",
16
+ "publishConfig": {
17
+ "registry": "http://lab.lan:4873/"
18
+ }
19
+ }