@rawdash/connector-sendgrid 0.28.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.
package/README.md ADDED
@@ -0,0 +1,133 @@
1
+ <!-- This file is generated from connector metadata by scripts/generate-connector-docs.ts. Do not edit by hand. -->
2
+
3
+ # @rawdash/connector-sendgrid
4
+
5
+ [![npm version](https://img.shields.io/npm/v/@rawdash/connector-sendgrid)](https://www.npmjs.com/package/@rawdash/connector-sendgrid)
6
+ [![license](https://img.shields.io/npm/l/@rawdash/connector-sendgrid)](https://github.com/rawdash/rawdash/blob/main/LICENSE)
7
+
8
+ Sync daily SendGrid email stats (sends, delivery rate, bounce rate, spam complaints, opens, clicks) plus bounce and spam-report events for transactional-email dashboards.
9
+
10
+ ## Install
11
+
12
+ ```sh
13
+ npm install @rawdash/connector-sendgrid
14
+ ```
15
+
16
+ ## Authentication
17
+
18
+ A SendGrid Web API v3 key sent as a bearer token.
19
+
20
+ 1. In SendGrid, open Settings -> API Keys and create a new API key.
21
+ 2. Grant it at least read access to Stats and Suppressions (Restricted Access -> Stats: Read, Suppressions: Read), or use a Full Access key.
22
+ 3. Store the key as a rawdash secret and reference it from config as `apiKey: secret("SENDGRID_API_KEY")`.
23
+
24
+ ## Configuration
25
+
26
+ | Field | Type | Required | Description |
27
+ | -------------- | ------ | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
28
+ | `apiKey` | secret | Yes | SendGrid Web API v3 key with read access to the Stats and Suppressions APIs. Create one under Settings -> API Keys. |
29
+ | `categories` | array | No | Optional list of SendGrid categories to break email stats down by. When set, daily stats are fetched per category from the Category Stats endpoint; when omitted, account-wide global stats are fetched and tagged with the category "all". |
30
+ | `backfillDays` | number | No | How many trailing days of email stats, bounces, and spam reports to pull on a full sync. Defaults to 90. |
31
+ | `resources` | array | No | Which SendGrid resources to sync. Omit to sync all of them. |
32
+
33
+ ## Resources
34
+
35
+ - **`sendgrid_email_stats`** _(metric)_ - Daily email engagement stats (requests, delivered, bounces, spam reports, opens, clicks, unsubscribes) from the SendGrid Stats API, one sample per (day, category). The sample value is the number of requests (sends); every other counter is exposed as an attribute.
36
+ - Endpoint: `GET /stats`
37
+ - Unit: emails
38
+ - Granularity: 1d
39
+ - Dimensions: `category`, `requests`, `delivered`, `bounces`, `bounceDrops`, `blocks`, `deferred`, `invalidEmails`, `processed`, `opens`, `uniqueOpens`, `clicks`, `uniqueClicks`, `spamReports`, `spamReportDrops`, `unsubscribes`, `unsubscribeDrops`
40
+ - Aggregated by day. The metric scope is cleared and rewritten on every sync because aggregate daily stats cannot be upserted by key. When categories are configured the Category Stats endpoint (GET /categories/stats) is used instead and the category dimension carries the category name.
41
+ - **`sendgrid_bounce`** _(event)_ - Bounce events from the SendGrid Suppressions API. One event per bounced address, timestamped at the bounce time.
42
+ - Endpoint: `GET /suppression/bounces`
43
+ - Paginated via limit / offset over the [start_time, end_time] window. Incremental syncs pull from the last sync time forward.
44
+ - `email`: Recipient address that bounced.
45
+ - `reason`: Reason reported by the receiving server.
46
+ - `status`: SMTP status code for the bounce.
47
+ - **`sendgrid_spam_report`** _(event)_ - Spam-report (complaint) events from the SendGrid Suppressions API. One event per complaining address, timestamped at the report time.
48
+ - Endpoint: `GET /suppression/spam_reports`
49
+ - Paginated via limit / offset over the [start_time, end_time] window. Incremental syncs pull from the last sync time forward.
50
+ - `email`: Recipient address that reported spam.
51
+ - `ip`: Sending IP the complaint was attributed to.
52
+
53
+ ## Example
54
+
55
+ ```ts
56
+ import {
57
+ defineConfig,
58
+ defineDashboard,
59
+ defineMetric,
60
+ secret,
61
+ } from '@rawdash/core';
62
+
63
+ const sendgrid = {
64
+ name: 'sendgrid',
65
+ connectorId: 'sendgrid',
66
+ config: {
67
+ apiKey: secret('SENDGRID_API_KEY'),
68
+ },
69
+ };
70
+
71
+ export default defineConfig({
72
+ connectors: [sendgrid],
73
+ dashboards: {
74
+ email: defineDashboard({
75
+ widgets: {
76
+ sends: {
77
+ kind: 'stat',
78
+ title: 'Emails sent (last 30d)',
79
+ metric: defineMetric({
80
+ connector: sendgrid,
81
+ shape: 'metric',
82
+ name: 'sendgrid_email_stats',
83
+ field: 'requests',
84
+ fn: 'sum',
85
+ }),
86
+ },
87
+ bounces: {
88
+ kind: 'stat',
89
+ title: 'Bounces (last 30d)',
90
+ metric: defineMetric({
91
+ connector: sendgrid,
92
+ shape: 'event',
93
+ name: 'sendgrid_bounce',
94
+ fn: 'count',
95
+ }),
96
+ },
97
+ daily_volume: {
98
+ kind: 'timeseries',
99
+ title: 'Daily email volume',
100
+ window: '30d',
101
+ metric: defineMetric({
102
+ connector: sendgrid,
103
+ shape: 'metric',
104
+ name: 'sendgrid_email_stats',
105
+ field: 'requests',
106
+ fn: 'sum',
107
+ }),
108
+ },
109
+ },
110
+ }),
111
+ },
112
+ });
113
+ ```
114
+
115
+ ## Rate limits
116
+
117
+ SendGrid returns X-RateLimit-Remaining / X-RateLimit-Reset response headers; the shared HTTP client backs off on 429 using the standard rate-limit policy.
118
+
119
+ ## Limitations
120
+
121
+ - Email stats are a daily aggregate series: each sync clears the metric scope and rewrites the requested window, so incremental syncs only refresh the trailing window (default 2 days) while full syncs repopulate the whole backfill window.
122
+ - Category-level stats require the categories to be listed in config; SendGrid has no "all categories" stats call.
123
+ - Bounce and spam-report events are read from the Suppressions API and are limited to addresses still present in the suppression lists; entries removed from SendGrid are not retained.
124
+
125
+ ## Links
126
+
127
+ - [Rawdash docs](https://rawdash.dev/docs/connectors)
128
+ - [SendGrid API docs](https://docs.sendgrid.com/api-reference/)
129
+ - [GitHub](https://github.com/rawdash/rawdash)
130
+
131
+ ## License
132
+
133
+ Apache-2.0
@@ -0,0 +1,368 @@
1
+ import { BaseConnector, ConnectorContext, SyncOptions, StorageHandle, SyncResult, ConnectorDoc } from '@rawdash/core';
2
+ import { z } from 'zod';
3
+
4
+ declare const configFields: z.ZodObject<{
5
+ apiKey: z.ZodObject<{
6
+ $secret: z.ZodString;
7
+ }, z.core.$strip>;
8
+ categories: z.ZodOptional<z.ZodArray<z.ZodString>>;
9
+ backfillDays: z.ZodOptional<z.ZodNumber>;
10
+ resources: z.ZodOptional<z.ZodArray<z.ZodEnum<{
11
+ email_stats: "email_stats";
12
+ bounces: "bounces";
13
+ spam_reports: "spam_reports";
14
+ }>>>;
15
+ }, z.core.$strip>;
16
+ declare const doc: ConnectorDoc;
17
+ type SendgridResource = 'email_stats' | 'bounces' | 'spam_reports';
18
+ interface SendgridSettings {
19
+ categories?: readonly string[];
20
+ backfillDays?: number;
21
+ resources?: readonly SendgridResource[];
22
+ }
23
+ declare const sendgridCredentials: {
24
+ apiKey: {
25
+ description: string;
26
+ auth: "required";
27
+ };
28
+ };
29
+ type SendgridCredentials = typeof sendgridCredentials;
30
+ declare const sendgridResources: {
31
+ readonly sendgrid_email_stats: {
32
+ readonly shape: "metric";
33
+ readonly description: "Daily email engagement stats (requests, delivered, bounces, spam reports, opens, clicks, unsubscribes) from the SendGrid Stats API, one sample per (day, category). The sample value is the number of requests (sends); every other counter is exposed as an attribute.";
34
+ readonly endpoint: "GET /stats";
35
+ readonly unit: "emails";
36
+ readonly granularity: "1d";
37
+ readonly notes: "Aggregated by day. The metric scope is cleared and rewritten on every sync because aggregate daily stats cannot be upserted by key. When categories are configured the Category Stats endpoint (GET /categories/stats) is used instead and the category dimension carries the category name.";
38
+ readonly dimensions: [{
39
+ readonly name: "category";
40
+ readonly description: "SendGrid category, or \"all\" for account-wide global stats.";
41
+ }, {
42
+ readonly name: "requests";
43
+ readonly description: "Emails requested (sends).";
44
+ }, {
45
+ readonly name: "delivered";
46
+ readonly description: "Emails delivered.";
47
+ }, {
48
+ readonly name: "bounces";
49
+ readonly description: "Bounced emails.";
50
+ }, {
51
+ readonly name: "bounceDrops";
52
+ readonly description: "Emails dropped due to prior bounces.";
53
+ }, {
54
+ readonly name: "blocks";
55
+ readonly description: "Blocked emails.";
56
+ }, {
57
+ readonly name: "deferred";
58
+ readonly description: "Temporarily deferred emails.";
59
+ }, {
60
+ readonly name: "invalidEmails";
61
+ readonly description: "Invalid recipient addresses.";
62
+ }, {
63
+ readonly name: "processed";
64
+ readonly description: "Emails processed.";
65
+ }, {
66
+ readonly name: "opens";
67
+ readonly description: "Total opens.";
68
+ }, {
69
+ readonly name: "uniqueOpens";
70
+ readonly description: "Unique opens.";
71
+ }, {
72
+ readonly name: "clicks";
73
+ readonly description: "Total clicks.";
74
+ }, {
75
+ readonly name: "uniqueClicks";
76
+ readonly description: "Unique clicks.";
77
+ }, {
78
+ readonly name: "spamReports";
79
+ readonly description: "Spam complaints.";
80
+ }, {
81
+ readonly name: "spamReportDrops";
82
+ readonly description: "Emails dropped due to prior spam reports.";
83
+ }, {
84
+ readonly name: "unsubscribes";
85
+ readonly description: "Unsubscribes.";
86
+ }, {
87
+ readonly name: "unsubscribeDrops";
88
+ readonly description: "Emails dropped due to prior unsubscribes.";
89
+ }];
90
+ readonly responses: {
91
+ readonly email_stats: z.ZodArray<z.ZodObject<{
92
+ date: z.ZodString;
93
+ stats: z.ZodOptional<z.ZodNullable<z.ZodArray<z.ZodObject<{
94
+ type: z.ZodOptional<z.ZodNullable<z.ZodString>>;
95
+ name: z.ZodOptional<z.ZodNullable<z.ZodString>>;
96
+ metrics: z.ZodOptional<z.ZodNullable<z.ZodObject<{
97
+ blocks: z.ZodOptional<z.ZodNullable<z.ZodNumber>>;
98
+ bounce_drops: z.ZodOptional<z.ZodNullable<z.ZodNumber>>;
99
+ bounces: z.ZodOptional<z.ZodNullable<z.ZodNumber>>;
100
+ clicks: z.ZodOptional<z.ZodNullable<z.ZodNumber>>;
101
+ deferred: z.ZodOptional<z.ZodNullable<z.ZodNumber>>;
102
+ delivered: z.ZodOptional<z.ZodNullable<z.ZodNumber>>;
103
+ invalid_emails: z.ZodOptional<z.ZodNullable<z.ZodNumber>>;
104
+ opens: z.ZodOptional<z.ZodNullable<z.ZodNumber>>;
105
+ processed: z.ZodOptional<z.ZodNullable<z.ZodNumber>>;
106
+ requests: z.ZodOptional<z.ZodNullable<z.ZodNumber>>;
107
+ spam_report_drops: z.ZodOptional<z.ZodNullable<z.ZodNumber>>;
108
+ spam_reports: z.ZodOptional<z.ZodNullable<z.ZodNumber>>;
109
+ unique_clicks: z.ZodOptional<z.ZodNullable<z.ZodNumber>>;
110
+ unique_opens: z.ZodOptional<z.ZodNullable<z.ZodNumber>>;
111
+ unsubscribe_drops: z.ZodOptional<z.ZodNullable<z.ZodNumber>>;
112
+ unsubscribes: z.ZodOptional<z.ZodNullable<z.ZodNumber>>;
113
+ }, z.core.$strip>>>;
114
+ }, z.core.$strip>>>>;
115
+ }, z.core.$strip>>;
116
+ };
117
+ };
118
+ readonly sendgrid_bounce: {
119
+ readonly shape: "event";
120
+ readonly filterable: [];
121
+ readonly description: "Bounce events from the SendGrid Suppressions API. One event per bounced address, timestamped at the bounce time.";
122
+ readonly endpoint: "GET /suppression/bounces";
123
+ readonly notes: "Paginated via limit / offset over the [start_time, end_time] window. Incremental syncs pull from the last sync time forward.";
124
+ readonly fields: [{
125
+ readonly name: "email";
126
+ readonly description: "Recipient address that bounced.";
127
+ }, {
128
+ readonly name: "reason";
129
+ readonly description: "Reason reported by the receiving server.";
130
+ }, {
131
+ readonly name: "status";
132
+ readonly description: "SMTP status code for the bounce.";
133
+ }];
134
+ readonly responses: {
135
+ readonly bounces: z.ZodArray<z.ZodObject<{
136
+ created: z.ZodNumber;
137
+ email: z.ZodString;
138
+ reason: z.ZodOptional<z.ZodNullable<z.ZodString>>;
139
+ status: z.ZodOptional<z.ZodNullable<z.ZodString>>;
140
+ }, z.core.$strip>>;
141
+ };
142
+ };
143
+ readonly sendgrid_spam_report: {
144
+ readonly shape: "event";
145
+ readonly filterable: [];
146
+ readonly description: "Spam-report (complaint) events from the SendGrid Suppressions API. One event per complaining address, timestamped at the report time.";
147
+ readonly endpoint: "GET /suppression/spam_reports";
148
+ readonly notes: "Paginated via limit / offset over the [start_time, end_time] window. Incremental syncs pull from the last sync time forward.";
149
+ readonly fields: [{
150
+ readonly name: "email";
151
+ readonly description: "Recipient address that reported spam.";
152
+ }, {
153
+ readonly name: "ip";
154
+ readonly description: "Sending IP the complaint was attributed to.";
155
+ }];
156
+ readonly responses: {
157
+ readonly spam_reports: z.ZodArray<z.ZodObject<{
158
+ created: z.ZodNumber;
159
+ email: z.ZodString;
160
+ ip: z.ZodOptional<z.ZodNullable<z.ZodString>>;
161
+ }, z.core.$strip>>;
162
+ };
163
+ };
164
+ };
165
+ declare const id = "sendgrid";
166
+ declare class SendgridConnector extends BaseConnector<SendgridSettings, SendgridCredentials> {
167
+ static readonly id = "sendgrid";
168
+ static readonly resources: {
169
+ readonly sendgrid_email_stats: {
170
+ readonly shape: "metric";
171
+ readonly description: "Daily email engagement stats (requests, delivered, bounces, spam reports, opens, clicks, unsubscribes) from the SendGrid Stats API, one sample per (day, category). The sample value is the number of requests (sends); every other counter is exposed as an attribute.";
172
+ readonly endpoint: "GET /stats";
173
+ readonly unit: "emails";
174
+ readonly granularity: "1d";
175
+ readonly notes: "Aggregated by day. The metric scope is cleared and rewritten on every sync because aggregate daily stats cannot be upserted by key. When categories are configured the Category Stats endpoint (GET /categories/stats) is used instead and the category dimension carries the category name.";
176
+ readonly dimensions: [{
177
+ readonly name: "category";
178
+ readonly description: "SendGrid category, or \"all\" for account-wide global stats.";
179
+ }, {
180
+ readonly name: "requests";
181
+ readonly description: "Emails requested (sends).";
182
+ }, {
183
+ readonly name: "delivered";
184
+ readonly description: "Emails delivered.";
185
+ }, {
186
+ readonly name: "bounces";
187
+ readonly description: "Bounced emails.";
188
+ }, {
189
+ readonly name: "bounceDrops";
190
+ readonly description: "Emails dropped due to prior bounces.";
191
+ }, {
192
+ readonly name: "blocks";
193
+ readonly description: "Blocked emails.";
194
+ }, {
195
+ readonly name: "deferred";
196
+ readonly description: "Temporarily deferred emails.";
197
+ }, {
198
+ readonly name: "invalidEmails";
199
+ readonly description: "Invalid recipient addresses.";
200
+ }, {
201
+ readonly name: "processed";
202
+ readonly description: "Emails processed.";
203
+ }, {
204
+ readonly name: "opens";
205
+ readonly description: "Total opens.";
206
+ }, {
207
+ readonly name: "uniqueOpens";
208
+ readonly description: "Unique opens.";
209
+ }, {
210
+ readonly name: "clicks";
211
+ readonly description: "Total clicks.";
212
+ }, {
213
+ readonly name: "uniqueClicks";
214
+ readonly description: "Unique clicks.";
215
+ }, {
216
+ readonly name: "spamReports";
217
+ readonly description: "Spam complaints.";
218
+ }, {
219
+ readonly name: "spamReportDrops";
220
+ readonly description: "Emails dropped due to prior spam reports.";
221
+ }, {
222
+ readonly name: "unsubscribes";
223
+ readonly description: "Unsubscribes.";
224
+ }, {
225
+ readonly name: "unsubscribeDrops";
226
+ readonly description: "Emails dropped due to prior unsubscribes.";
227
+ }];
228
+ readonly responses: {
229
+ readonly email_stats: z.ZodArray<z.ZodObject<{
230
+ date: z.ZodString;
231
+ stats: z.ZodOptional<z.ZodNullable<z.ZodArray<z.ZodObject<{
232
+ type: z.ZodOptional<z.ZodNullable<z.ZodString>>;
233
+ name: z.ZodOptional<z.ZodNullable<z.ZodString>>;
234
+ metrics: z.ZodOptional<z.ZodNullable<z.ZodObject<{
235
+ blocks: z.ZodOptional<z.ZodNullable<z.ZodNumber>>;
236
+ bounce_drops: z.ZodOptional<z.ZodNullable<z.ZodNumber>>;
237
+ bounces: z.ZodOptional<z.ZodNullable<z.ZodNumber>>;
238
+ clicks: z.ZodOptional<z.ZodNullable<z.ZodNumber>>;
239
+ deferred: z.ZodOptional<z.ZodNullable<z.ZodNumber>>;
240
+ delivered: z.ZodOptional<z.ZodNullable<z.ZodNumber>>;
241
+ invalid_emails: z.ZodOptional<z.ZodNullable<z.ZodNumber>>;
242
+ opens: z.ZodOptional<z.ZodNullable<z.ZodNumber>>;
243
+ processed: z.ZodOptional<z.ZodNullable<z.ZodNumber>>;
244
+ requests: z.ZodOptional<z.ZodNullable<z.ZodNumber>>;
245
+ spam_report_drops: z.ZodOptional<z.ZodNullable<z.ZodNumber>>;
246
+ spam_reports: z.ZodOptional<z.ZodNullable<z.ZodNumber>>;
247
+ unique_clicks: z.ZodOptional<z.ZodNullable<z.ZodNumber>>;
248
+ unique_opens: z.ZodOptional<z.ZodNullable<z.ZodNumber>>;
249
+ unsubscribe_drops: z.ZodOptional<z.ZodNullable<z.ZodNumber>>;
250
+ unsubscribes: z.ZodOptional<z.ZodNullable<z.ZodNumber>>;
251
+ }, z.core.$strip>>>;
252
+ }, z.core.$strip>>>>;
253
+ }, z.core.$strip>>;
254
+ };
255
+ };
256
+ readonly sendgrid_bounce: {
257
+ readonly shape: "event";
258
+ readonly filterable: [];
259
+ readonly description: "Bounce events from the SendGrid Suppressions API. One event per bounced address, timestamped at the bounce time.";
260
+ readonly endpoint: "GET /suppression/bounces";
261
+ readonly notes: "Paginated via limit / offset over the [start_time, end_time] window. Incremental syncs pull from the last sync time forward.";
262
+ readonly fields: [{
263
+ readonly name: "email";
264
+ readonly description: "Recipient address that bounced.";
265
+ }, {
266
+ readonly name: "reason";
267
+ readonly description: "Reason reported by the receiving server.";
268
+ }, {
269
+ readonly name: "status";
270
+ readonly description: "SMTP status code for the bounce.";
271
+ }];
272
+ readonly responses: {
273
+ readonly bounces: z.ZodArray<z.ZodObject<{
274
+ created: z.ZodNumber;
275
+ email: z.ZodString;
276
+ reason: z.ZodOptional<z.ZodNullable<z.ZodString>>;
277
+ status: z.ZodOptional<z.ZodNullable<z.ZodString>>;
278
+ }, z.core.$strip>>;
279
+ };
280
+ };
281
+ readonly sendgrid_spam_report: {
282
+ readonly shape: "event";
283
+ readonly filterable: [];
284
+ readonly description: "Spam-report (complaint) events from the SendGrid Suppressions API. One event per complaining address, timestamped at the report time.";
285
+ readonly endpoint: "GET /suppression/spam_reports";
286
+ readonly notes: "Paginated via limit / offset over the [start_time, end_time] window. Incremental syncs pull from the last sync time forward.";
287
+ readonly fields: [{
288
+ readonly name: "email";
289
+ readonly description: "Recipient address that reported spam.";
290
+ }, {
291
+ readonly name: "ip";
292
+ readonly description: "Sending IP the complaint was attributed to.";
293
+ }];
294
+ readonly responses: {
295
+ readonly spam_reports: z.ZodArray<z.ZodObject<{
296
+ created: z.ZodNumber;
297
+ email: z.ZodString;
298
+ ip: z.ZodOptional<z.ZodNullable<z.ZodString>>;
299
+ }, z.core.$strip>>;
300
+ };
301
+ };
302
+ };
303
+ static readonly schemas: {
304
+ readonly email_stats: z.ZodArray<z.ZodObject<{
305
+ date: z.ZodString;
306
+ stats: z.ZodOptional<z.ZodNullable<z.ZodArray<z.ZodObject<{
307
+ type: z.ZodOptional<z.ZodNullable<z.ZodString>>;
308
+ name: z.ZodOptional<z.ZodNullable<z.ZodString>>;
309
+ metrics: z.ZodOptional<z.ZodNullable<z.ZodObject<{
310
+ blocks: z.ZodOptional<z.ZodNullable<z.ZodNumber>>;
311
+ bounce_drops: z.ZodOptional<z.ZodNullable<z.ZodNumber>>;
312
+ bounces: z.ZodOptional<z.ZodNullable<z.ZodNumber>>;
313
+ clicks: z.ZodOptional<z.ZodNullable<z.ZodNumber>>;
314
+ deferred: z.ZodOptional<z.ZodNullable<z.ZodNumber>>;
315
+ delivered: z.ZodOptional<z.ZodNullable<z.ZodNumber>>;
316
+ invalid_emails: z.ZodOptional<z.ZodNullable<z.ZodNumber>>;
317
+ opens: z.ZodOptional<z.ZodNullable<z.ZodNumber>>;
318
+ processed: z.ZodOptional<z.ZodNullable<z.ZodNumber>>;
319
+ requests: z.ZodOptional<z.ZodNullable<z.ZodNumber>>;
320
+ spam_report_drops: z.ZodOptional<z.ZodNullable<z.ZodNumber>>;
321
+ spam_reports: z.ZodOptional<z.ZodNullable<z.ZodNumber>>;
322
+ unique_clicks: z.ZodOptional<z.ZodNullable<z.ZodNumber>>;
323
+ unique_opens: z.ZodOptional<z.ZodNullable<z.ZodNumber>>;
324
+ unsubscribe_drops: z.ZodOptional<z.ZodNullable<z.ZodNumber>>;
325
+ unsubscribes: z.ZodOptional<z.ZodNullable<z.ZodNumber>>;
326
+ }, z.core.$strip>>>;
327
+ }, z.core.$strip>>>>;
328
+ }, z.core.$strip>>;
329
+ } & {
330
+ readonly bounces: z.ZodArray<z.ZodObject<{
331
+ created: z.ZodNumber;
332
+ email: z.ZodString;
333
+ reason: z.ZodOptional<z.ZodNullable<z.ZodString>>;
334
+ status: z.ZodOptional<z.ZodNullable<z.ZodString>>;
335
+ }, z.core.$strip>>;
336
+ } & {
337
+ readonly spam_reports: z.ZodArray<z.ZodObject<{
338
+ created: z.ZodNumber;
339
+ email: z.ZodString;
340
+ ip: z.ZodOptional<z.ZodNullable<z.ZodString>>;
341
+ }, z.core.$strip>>;
342
+ } & Readonly<Record<string, z.ZodType<unknown, unknown, z.core.$ZodTypeInternals<unknown, unknown>>>>;
343
+ static create(input: unknown, ctx?: ConnectorContext): SendgridConnector;
344
+ readonly id = "sendgrid";
345
+ readonly credentials: {
346
+ apiKey: {
347
+ description: string;
348
+ auth: "required";
349
+ };
350
+ };
351
+ private get backfillDays();
352
+ private buildHeaders;
353
+ private apiGet;
354
+ private statsDateRange;
355
+ private suppressionWindow;
356
+ private buildStatsUrl;
357
+ private buildSuppressionUrl;
358
+ private fetchStats;
359
+ private fetchSuppressionPage;
360
+ private writeEmailStats;
361
+ private writeBounces;
362
+ private writeSpamReports;
363
+ private clearScopeOnFirstPage;
364
+ private writePhase;
365
+ sync(options: SyncOptions, storage: StorageHandle, signal?: AbortSignal): Promise<SyncResult>;
366
+ }
367
+
368
+ export { SendgridConnector, type SendgridResource, type SendgridSettings, configFields, SendgridConnector as default, doc, id, sendgridResources as resources };
package/dist/index.js ADDED
@@ -0,0 +1,554 @@
1
+ // ../../connector-shared/dist/index.js
2
+ var HTTP_CLIENT_VERSION = "0.0.0";
3
+ var DEFAULT_USER_AGENT = `rawdash-connector/${HTTP_CLIENT_VERSION} (+https://rawdash.dev)`;
4
+ function connectorUserAgent(connectorId) {
5
+ return `rawdash-connector-${connectorId}/${HTTP_CLIENT_VERSION} (+https://rawdash.dev)`;
6
+ }
7
+ function standardRateLimitPolicy(config) {
8
+ const { remainingHeader, resetHeader, resetUnit, resetFallbackMs } = config;
9
+ const multiplier = resetUnit === "s" ? 1e3 : 1;
10
+ return {
11
+ parse(h) {
12
+ const remainingRaw = h.get(remainingHeader);
13
+ if (remainingRaw === null || remainingRaw.trim() === "") {
14
+ return null;
15
+ }
16
+ const remaining = Number(remainingRaw);
17
+ if (!Number.isFinite(remaining)) {
18
+ return null;
19
+ }
20
+ const resetRaw = h.get(resetHeader);
21
+ if (resetRaw === null) {
22
+ if (resetFallbackMs === void 0) {
23
+ return null;
24
+ }
25
+ return {
26
+ remaining,
27
+ resetAt: new Date(Date.now() + resetFallbackMs)
28
+ };
29
+ }
30
+ if (resetRaw.trim() === "") {
31
+ return null;
32
+ }
33
+ const reset = Number(resetRaw);
34
+ if (!Number.isFinite(reset) || reset < 0) {
35
+ return null;
36
+ }
37
+ const resetMs = reset * multiplier;
38
+ if (!Number.isFinite(resetMs)) {
39
+ return null;
40
+ }
41
+ return { remaining, resetAt: new Date(resetMs) };
42
+ }
43
+ };
44
+ }
45
+ function parseEpoch(value, unit) {
46
+ if (value === null || value === void 0) {
47
+ return null;
48
+ }
49
+ if (unit === "iso") {
50
+ if (typeof value !== "string") {
51
+ return null;
52
+ }
53
+ const ms = new Date(value).getTime();
54
+ return Number.isFinite(ms) ? ms : null;
55
+ }
56
+ if (typeof value === "string" && value.trim() === "") {
57
+ return null;
58
+ }
59
+ const n = typeof value === "number" ? value : Number(value);
60
+ if (!Number.isFinite(n)) {
61
+ return null;
62
+ }
63
+ const result = unit === "s" ? n * 1e3 : n;
64
+ return Number.isFinite(result) ? result : null;
65
+ }
66
+
67
+ // src/sendgrid.ts
68
+ import {
69
+ BaseConnector,
70
+ defineConfigFields,
71
+ defineConnectorDoc,
72
+ defineResources,
73
+ makeChunkedCursorGuard,
74
+ paginateChunked,
75
+ schemasFromResources,
76
+ selectActivePhases
77
+ } from "@rawdash/core";
78
+ import { z } from "zod";
79
+ var configFields = defineConfigFields(
80
+ z.object({
81
+ apiKey: z.object({ $secret: z.string().min(1) }).meta({
82
+ label: "API key",
83
+ description: "SendGrid Web API v3 key with read access to the Stats and Suppressions APIs. Create one under Settings -> API Keys.",
84
+ placeholder: "SENDGRID_API_KEY",
85
+ secret: true
86
+ }),
87
+ categories: z.array(z.string().min(1)).nonempty().optional().meta({
88
+ label: "Categories",
89
+ description: 'Optional list of SendGrid categories to break email stats down by. When set, daily stats are fetched per category from the Category Stats endpoint; when omitted, account-wide global stats are fetched and tagged with the category "all".'
90
+ }),
91
+ backfillDays: z.number().int().positive().optional().meta({
92
+ label: "Backfill window (days)",
93
+ description: "How many trailing days of email stats, bounces, and spam reports to pull on a full sync. Defaults to 90.",
94
+ placeholder: "90"
95
+ }),
96
+ resources: z.array(z.enum(["email_stats", "bounces", "spam_reports"])).nonempty().optional().meta({
97
+ label: "Resources",
98
+ description: "Which SendGrid resources to sync. Omit to sync all of them."
99
+ })
100
+ })
101
+ );
102
+ var doc = defineConnectorDoc({
103
+ displayName: "SendGrid",
104
+ category: "marketing",
105
+ brandColor: "#1A82E2",
106
+ tagline: "Sync daily SendGrid email stats (sends, delivery rate, bounce rate, spam complaints, opens, clicks) plus bounce and spam-report events for transactional-email dashboards.",
107
+ vendor: {
108
+ name: "SendGrid",
109
+ domain: "sendgrid.com",
110
+ apiDocs: "https://docs.sendgrid.com/api-reference/",
111
+ website: "https://sendgrid.com"
112
+ },
113
+ auth: {
114
+ summary: "A SendGrid Web API v3 key sent as a bearer token.",
115
+ setup: [
116
+ "In SendGrid, open Settings -> API Keys and create a new API key.",
117
+ "Grant it at least read access to Stats and Suppressions (Restricted Access -> Stats: Read, Suppressions: Read), or use a Full Access key.",
118
+ 'Store the key as a rawdash secret and reference it from config as `apiKey: secret("SENDGRID_API_KEY")`.'
119
+ ]
120
+ },
121
+ rateLimit: "SendGrid returns X-RateLimit-Remaining / X-RateLimit-Reset response headers; the shared HTTP client backs off on 429 using the standard rate-limit policy.",
122
+ limitations: [
123
+ "Email stats are a daily aggregate series: each sync clears the metric scope and rewrites the requested window, so incremental syncs only refresh the trailing window (default 2 days) while full syncs repopulate the whole backfill window.",
124
+ 'Category-level stats require the categories to be listed in config; SendGrid has no "all categories" stats call.',
125
+ "Bounce and spam-report events are read from the Suppressions API and are limited to addresses still present in the suppression lists; entries removed from SendGrid are not retained."
126
+ ]
127
+ });
128
+ var sendgridCredentials = {
129
+ apiKey: {
130
+ description: "SendGrid Web API v3 key",
131
+ auth: "required"
132
+ }
133
+ };
134
+ var sendgridRateLimit = standardRateLimitPolicy({
135
+ remainingHeader: "x-ratelimit-remaining",
136
+ resetHeader: "x-ratelimit-reset",
137
+ resetUnit: "s"
138
+ });
139
+ var PHASE_ORDER = ["email_stats", "bounces", "spam_reports"];
140
+ var isSendgridSyncCursor = makeChunkedCursorGuard(PHASE_ORDER);
141
+ var EMAIL_STATS_METRIC = "sendgrid_email_stats";
142
+ var BOUNCE_EVENT = "sendgrid_bounce";
143
+ var SPAM_REPORT_EVENT = "sendgrid_spam_report";
144
+ var BASE_URL = "https://api.sendgrid.com/v3";
145
+ var SUPPRESSION_PAGE_SIZE = 500;
146
+ var MS_PER_DAY = 24 * 60 * 60 * 1e3;
147
+ var DEFAULT_BACKFILL_DAYS = 90;
148
+ var INCREMENTAL_LOOKBACK_DAYS = 2;
149
+ var GLOBAL_CATEGORY = "all";
150
+ var statMetricsSchema = z.object({
151
+ blocks: z.number().nullish(),
152
+ bounce_drops: z.number().nullish(),
153
+ bounces: z.number().nullish(),
154
+ clicks: z.number().nullish(),
155
+ deferred: z.number().nullish(),
156
+ delivered: z.number().nullish(),
157
+ invalid_emails: z.number().nullish(),
158
+ opens: z.number().nullish(),
159
+ processed: z.number().nullish(),
160
+ requests: z.number().nullish(),
161
+ spam_report_drops: z.number().nullish(),
162
+ spam_reports: z.number().nullish(),
163
+ unique_clicks: z.number().nullish(),
164
+ unique_opens: z.number().nullish(),
165
+ unsubscribe_drops: z.number().nullish(),
166
+ unsubscribes: z.number().nullish()
167
+ });
168
+ var statEntrySchema = z.object({
169
+ type: z.string().nullish(),
170
+ name: z.string().nullish(),
171
+ metrics: statMetricsSchema.nullish()
172
+ });
173
+ var statsDaySchema = z.object({
174
+ date: z.string(),
175
+ stats: z.array(statEntrySchema).nullish()
176
+ });
177
+ var statsResponseSchema = z.array(statsDaySchema);
178
+ var bounceSchema = z.object({
179
+ created: z.number(),
180
+ email: z.string(),
181
+ reason: z.string().nullish(),
182
+ status: z.string().nullish()
183
+ });
184
+ var bouncesResponseSchema = z.array(bounceSchema);
185
+ var spamReportSchema = z.object({
186
+ created: z.number(),
187
+ email: z.string(),
188
+ ip: z.string().nullish()
189
+ });
190
+ var spamReportsResponseSchema = z.array(spamReportSchema);
191
+ var sendgridResources = defineResources({
192
+ [EMAIL_STATS_METRIC]: {
193
+ shape: "metric",
194
+ description: "Daily email engagement stats (requests, delivered, bounces, spam reports, opens, clicks, unsubscribes) from the SendGrid Stats API, one sample per (day, category). The sample value is the number of requests (sends); every other counter is exposed as an attribute.",
195
+ endpoint: "GET /stats",
196
+ unit: "emails",
197
+ granularity: "1d",
198
+ notes: "Aggregated by day. The metric scope is cleared and rewritten on every sync because aggregate daily stats cannot be upserted by key. When categories are configured the Category Stats endpoint (GET /categories/stats) is used instead and the category dimension carries the category name.",
199
+ dimensions: [
200
+ {
201
+ name: "category",
202
+ description: 'SendGrid category, or "all" for account-wide global stats.'
203
+ },
204
+ { name: "requests", description: "Emails requested (sends)." },
205
+ { name: "delivered", description: "Emails delivered." },
206
+ { name: "bounces", description: "Bounced emails." },
207
+ {
208
+ name: "bounceDrops",
209
+ description: "Emails dropped due to prior bounces."
210
+ },
211
+ { name: "blocks", description: "Blocked emails." },
212
+ { name: "deferred", description: "Temporarily deferred emails." },
213
+ { name: "invalidEmails", description: "Invalid recipient addresses." },
214
+ { name: "processed", description: "Emails processed." },
215
+ { name: "opens", description: "Total opens." },
216
+ { name: "uniqueOpens", description: "Unique opens." },
217
+ { name: "clicks", description: "Total clicks." },
218
+ { name: "uniqueClicks", description: "Unique clicks." },
219
+ { name: "spamReports", description: "Spam complaints." },
220
+ {
221
+ name: "spamReportDrops",
222
+ description: "Emails dropped due to prior spam reports."
223
+ },
224
+ { name: "unsubscribes", description: "Unsubscribes." },
225
+ {
226
+ name: "unsubscribeDrops",
227
+ description: "Emails dropped due to prior unsubscribes."
228
+ }
229
+ ],
230
+ responses: { email_stats: statsResponseSchema }
231
+ },
232
+ [BOUNCE_EVENT]: {
233
+ shape: "event",
234
+ filterable: [],
235
+ description: "Bounce events from the SendGrid Suppressions API. One event per bounced address, timestamped at the bounce time.",
236
+ endpoint: "GET /suppression/bounces",
237
+ notes: "Paginated via limit / offset over the [start_time, end_time] window. Incremental syncs pull from the last sync time forward.",
238
+ fields: [
239
+ { name: "email", description: "Recipient address that bounced." },
240
+ {
241
+ name: "reason",
242
+ description: "Reason reported by the receiving server."
243
+ },
244
+ { name: "status", description: "SMTP status code for the bounce." }
245
+ ],
246
+ responses: { bounces: bouncesResponseSchema }
247
+ },
248
+ [SPAM_REPORT_EVENT]: {
249
+ shape: "event",
250
+ filterable: [],
251
+ description: "Spam-report (complaint) events from the SendGrid Suppressions API. One event per complaining address, timestamped at the report time.",
252
+ endpoint: "GET /suppression/spam_reports",
253
+ notes: "Paginated via limit / offset over the [start_time, end_time] window. Incremental syncs pull from the last sync time forward.",
254
+ fields: [
255
+ { name: "email", description: "Recipient address that reported spam." },
256
+ {
257
+ name: "ip",
258
+ description: "Sending IP the complaint was attributed to."
259
+ }
260
+ ],
261
+ responses: { spam_reports: spamReportsResponseSchema }
262
+ }
263
+ });
264
+ var id = "sendgrid";
265
+ function toYmd(ms) {
266
+ const d = new Date(ms);
267
+ const y = d.getUTCFullYear();
268
+ const m = String(d.getUTCMonth() + 1).padStart(2, "0");
269
+ const day = String(d.getUTCDate()).padStart(2, "0");
270
+ return `${y}-${m}-${day}`;
271
+ }
272
+ function ymdToMs(value) {
273
+ const m = /^(\d{4})-(\d{2})-(\d{2})/.exec(value);
274
+ if (!m) {
275
+ return null;
276
+ }
277
+ const ms = Date.UTC(Number(m[1]), Number(m[2]) - 1, Number(m[3]));
278
+ return Number.isFinite(ms) ? ms : null;
279
+ }
280
+ function counterValue(value) {
281
+ return typeof value === "number" && Number.isFinite(value) ? value : 0;
282
+ }
283
+ function offsetFromPage(page) {
284
+ if (page === null) {
285
+ return 0;
286
+ }
287
+ const n = Number(page);
288
+ return Number.isFinite(n) && n >= 0 ? n : 0;
289
+ }
290
+ var SendgridConnector = class _SendgridConnector extends BaseConnector {
291
+ static id = id;
292
+ static resources = sendgridResources;
293
+ static schemas = schemasFromResources(sendgridResources);
294
+ static create(input, ctx) {
295
+ const parsed = configFields.parse(input);
296
+ return new _SendgridConnector(
297
+ {
298
+ categories: parsed.categories,
299
+ backfillDays: parsed.backfillDays,
300
+ resources: parsed.resources
301
+ },
302
+ { apiKey: parsed.apiKey },
303
+ ctx
304
+ );
305
+ }
306
+ id = id;
307
+ credentials = sendgridCredentials;
308
+ get backfillDays() {
309
+ return this.settings.backfillDays ?? DEFAULT_BACKFILL_DAYS;
310
+ }
311
+ buildHeaders() {
312
+ return {
313
+ Authorization: `Bearer ${this.creds.apiKey}`,
314
+ Accept: "application/json",
315
+ "User-Agent": connectorUserAgent("sendgrid")
316
+ };
317
+ }
318
+ async apiGet(url, resource, signal) {
319
+ return this.get(url, {
320
+ resource,
321
+ headers: this.buildHeaders(),
322
+ rateLimit: sendgridRateLimit,
323
+ signal
324
+ });
325
+ }
326
+ statsDateRange(options) {
327
+ const now = Date.now();
328
+ const endDate = toYmd(now);
329
+ if (options.mode === "latest") {
330
+ const startMs2 = now - (INCREMENTAL_LOOKBACK_DAYS - 1) * MS_PER_DAY;
331
+ return { startDate: toYmd(startMs2), endDate };
332
+ }
333
+ if (options.since) {
334
+ const sinceMs = Date.parse(options.since);
335
+ if (Number.isFinite(sinceMs)) {
336
+ const days = Math.max(1, Math.ceil((now - sinceMs) / MS_PER_DAY));
337
+ const capped = Math.min(days, this.backfillDays);
338
+ const startMs2 = now - (capped - 1) * MS_PER_DAY;
339
+ return { startDate: toYmd(startMs2), endDate };
340
+ }
341
+ }
342
+ const startMs = now - (this.backfillDays - 1) * MS_PER_DAY;
343
+ return { startDate: toYmd(startMs), endDate };
344
+ }
345
+ suppressionWindow(options) {
346
+ const nowSec = Math.floor(Date.now() / 1e3);
347
+ if (options.mode !== "full" && options.since) {
348
+ const sinceMs = Date.parse(options.since);
349
+ if (Number.isFinite(sinceMs)) {
350
+ return { startTime: Math.floor(sinceMs / 1e3), endTime: nowSec };
351
+ }
352
+ }
353
+ return {
354
+ startTime: nowSec - this.backfillDays * 24 * 60 * 60,
355
+ endTime: nowSec
356
+ };
357
+ }
358
+ buildStatsUrl(range) {
359
+ const useCategories = this.settings.categories && this.settings.categories.length > 0;
360
+ const u = new URL(
361
+ `${BASE_URL}${useCategories ? "/categories/stats" : "/stats"}`
362
+ );
363
+ u.searchParams.set("start_date", range.startDate);
364
+ u.searchParams.set("end_date", range.endDate);
365
+ u.searchParams.set("aggregated_by", "day");
366
+ if (useCategories) {
367
+ for (const category of this.settings.categories) {
368
+ u.searchParams.append("categories", category);
369
+ }
370
+ }
371
+ return u.toString();
372
+ }
373
+ buildSuppressionUrl(path, window, offset) {
374
+ const u = new URL(`${BASE_URL}${path}`);
375
+ u.searchParams.set("start_time", String(window.startTime));
376
+ u.searchParams.set("end_time", String(window.endTime));
377
+ u.searchParams.set("limit", String(SUPPRESSION_PAGE_SIZE));
378
+ u.searchParams.set("offset", String(offset));
379
+ return u.toString();
380
+ }
381
+ async fetchStats(page, options, signal) {
382
+ if (page !== null) {
383
+ return { items: [], next: null };
384
+ }
385
+ const url = this.buildStatsUrl(this.statsDateRange(options));
386
+ const res = await this.apiGet(url, "email_stats", signal);
387
+ return { items: res.body, next: null };
388
+ }
389
+ async fetchSuppressionPage(path, resource, page, window, signal) {
390
+ const offset = offsetFromPage(page);
391
+ const url = this.buildSuppressionUrl(path, window, offset);
392
+ const res = await this.apiGet(url, resource, signal);
393
+ const items = res.body;
394
+ const next = items.length < SUPPRESSION_PAGE_SIZE ? null : String(offset + SUPPRESSION_PAGE_SIZE);
395
+ return { items, next };
396
+ }
397
+ async writeEmailStats(storage, items) {
398
+ const samples = [];
399
+ for (const day of items) {
400
+ const ts = ymdToMs(day.date);
401
+ if (ts === null) {
402
+ continue;
403
+ }
404
+ for (const entry of day.stats ?? []) {
405
+ const metrics = entry.metrics ?? {};
406
+ const category = entry.name ?? GLOBAL_CATEGORY;
407
+ samples.push({
408
+ name: EMAIL_STATS_METRIC,
409
+ ts,
410
+ value: counterValue(metrics.requests),
411
+ attributes: {
412
+ category,
413
+ requests: counterValue(metrics.requests),
414
+ delivered: counterValue(metrics.delivered),
415
+ bounces: counterValue(metrics.bounces),
416
+ bounceDrops: counterValue(metrics.bounce_drops),
417
+ blocks: counterValue(metrics.blocks),
418
+ deferred: counterValue(metrics.deferred),
419
+ invalidEmails: counterValue(metrics.invalid_emails),
420
+ processed: counterValue(metrics.processed),
421
+ opens: counterValue(metrics.opens),
422
+ uniqueOpens: counterValue(metrics.unique_opens),
423
+ clicks: counterValue(metrics.clicks),
424
+ uniqueClicks: counterValue(metrics.unique_clicks),
425
+ spamReports: counterValue(metrics.spam_reports),
426
+ spamReportDrops: counterValue(metrics.spam_report_drops),
427
+ unsubscribes: counterValue(metrics.unsubscribes),
428
+ unsubscribeDrops: counterValue(metrics.unsubscribe_drops)
429
+ }
430
+ });
431
+ }
432
+ }
433
+ await storage.metrics(samples, { names: [EMAIL_STATS_METRIC] });
434
+ }
435
+ async writeBounces(storage, items) {
436
+ for (const bounce of items) {
437
+ const ts = parseEpoch(bounce.created, "s");
438
+ if (ts === null) {
439
+ continue;
440
+ }
441
+ await storage.event({
442
+ name: BOUNCE_EVENT,
443
+ start_ts: ts,
444
+ end_ts: null,
445
+ attributes: {
446
+ email: bounce.email,
447
+ reason: bounce.reason ?? null,
448
+ status: bounce.status ?? null
449
+ }
450
+ });
451
+ }
452
+ }
453
+ async writeSpamReports(storage, items) {
454
+ for (const report of items) {
455
+ const ts = parseEpoch(report.created, "s");
456
+ if (ts === null) {
457
+ continue;
458
+ }
459
+ await storage.event({
460
+ name: SPAM_REPORT_EVENT,
461
+ start_ts: ts,
462
+ end_ts: null,
463
+ attributes: {
464
+ email: report.email,
465
+ ip: report.ip ?? null
466
+ }
467
+ });
468
+ }
469
+ }
470
+ async clearScopeOnFirstPage(storage, phase, isFull) {
471
+ switch (phase) {
472
+ case "email_stats":
473
+ return;
474
+ case "bounces":
475
+ if (isFull) {
476
+ await storage.events([], { names: [BOUNCE_EVENT] });
477
+ }
478
+ return;
479
+ case "spam_reports":
480
+ if (isFull) {
481
+ await storage.events([], { names: [SPAM_REPORT_EVENT] });
482
+ }
483
+ return;
484
+ }
485
+ }
486
+ async writePhase(storage, phase, items) {
487
+ switch (phase) {
488
+ case "email_stats":
489
+ return this.writeEmailStats(storage, items);
490
+ case "bounces":
491
+ return this.writeBounces(storage, items);
492
+ case "spam_reports":
493
+ return this.writeSpamReports(storage, items);
494
+ }
495
+ }
496
+ async sync(options, storage, signal) {
497
+ const cursor = isSendgridSyncCursor(
498
+ options.cursor
499
+ ) ? options.cursor : void 0;
500
+ const isFull = options.mode === "full";
501
+ const phases = selectActivePhases(
502
+ (r) => r,
503
+ PHASE_ORDER,
504
+ this.settings.resources
505
+ );
506
+ const window = this.suppressionWindow(options);
507
+ return paginateChunked({
508
+ phases,
509
+ cursor,
510
+ signal,
511
+ logger: this.logger,
512
+ fetchPage: async (phase, page, sig) => {
513
+ switch (phase) {
514
+ case "email_stats":
515
+ return this.fetchStats(page, options, sig);
516
+ case "bounces":
517
+ return this.fetchSuppressionPage(
518
+ "/suppression/bounces",
519
+ "bounces",
520
+ page,
521
+ window,
522
+ sig
523
+ );
524
+ case "spam_reports":
525
+ return this.fetchSuppressionPage(
526
+ "/suppression/spam_reports",
527
+ "spam_reports",
528
+ page,
529
+ window,
530
+ sig
531
+ );
532
+ }
533
+ },
534
+ writeBatch: async (phase, items, page) => {
535
+ if (page === null) {
536
+ await this.clearScopeOnFirstPage(storage, phase, isFull);
537
+ }
538
+ await this.writePhase(storage, phase, items);
539
+ }
540
+ });
541
+ }
542
+ };
543
+
544
+ // src/index.ts
545
+ var index_default = SendgridConnector;
546
+ export {
547
+ SendgridConnector,
548
+ configFields,
549
+ index_default as default,
550
+ doc,
551
+ id,
552
+ sendgridResources as resources
553
+ };
554
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../../connector-shared/src/errors.ts","../../../connector-shared/src/retry.ts","../../../connector-shared/src/version.ts","../../../connector-shared/src/request.ts","../../../connector-shared/src/rate-limit.ts","../../../connector-shared/src/map-concurrent.ts","../../../connector-shared/src/sanitize.ts","../../../connector-shared/src/epoch.ts","../../../connector-shared/src/pagination.ts","../../../connector-shared/src/logger.ts","../src/sendgrid.ts","../src/index.ts"],"sourcesContent":["import type { HttpResponse } from './types';\n\nexport type HttpErrorKind =\n | 'transient'\n | 'rate_limit'\n | 'auth'\n | 'upstream_bug'\n | 'client_bug';\n\nexport abstract class HttpClientError extends Error {\n abstract readonly kind: HttpErrorKind;\n readonly response?: HttpResponse;\n\n constructor(message: string, response?: HttpResponse) {\n super(message);\n this.name = new.target.name;\n this.response = response;\n }\n}\n\nexport class TransientError extends HttpClientError {\n readonly kind = 'transient' as const;\n}\n\nexport class RateLimitError extends HttpClientError {\n readonly kind = 'rate_limit' as const;\n readonly retryAfter?: Date;\n\n constructor(message: string, response?: HttpResponse, retryAfter?: Date) {\n super(message, response);\n this.retryAfter = retryAfter;\n }\n}\n\nexport class AuthError extends HttpClientError {\n readonly kind = 'auth' as const;\n}\n\nexport class UpstreamBugError extends HttpClientError {\n readonly kind = 'upstream_bug' as const;\n}\n\nexport class ClientBugError extends HttpClientError {\n readonly kind = 'client_bug' as const;\n}\n\nexport function classifyStatus(status: number): HttpErrorKind {\n if (status === 429) {\n return 'rate_limit';\n }\n if (status === 401 || status === 403) {\n return 'auth';\n }\n if (status === 408) {\n return 'transient';\n }\n if (status >= 500) {\n return 'upstream_bug';\n }\n if (status >= 400) {\n return 'client_bug';\n }\n return 'client_bug';\n}\n\nexport function errorForStatus(\n message: string,\n response: HttpResponse,\n retryAfter?: Date,\n): HttpClientError {\n const kind = classifyStatus(response.status);\n switch (kind) {\n case 'rate_limit':\n return new RateLimitError(message, response, retryAfter);\n case 'auth':\n return new AuthError(message, response);\n case 'transient':\n return new TransientError(message, response);\n case 'upstream_bug':\n return new UpstreamBugError(message, response);\n case 'client_bug':\n return new ClientBugError(message, response);\n }\n}\n","import { HttpClientError, RateLimitError, TransientError } from './errors';\n\nexport interface RetryPolicy {\n maxAttempts?: number;\n initialDelayMs?: number;\n maxDelayMs?: number;\n retryOn?: (status: number | null, err?: Error) => boolean;\n}\n\nexport const defaultRetryOn = (status: number | null, err?: Error): boolean => {\n if (err instanceof RateLimitError) {\n return true;\n }\n if (err instanceof TransientError) {\n return true;\n }\n if (status === null) {\n return err instanceof Error && !(err instanceof HttpClientError);\n }\n if (status === 408 || status === 429) {\n return true;\n }\n if (status >= 500) {\n return true;\n }\n return false;\n};\n\nexport function backoffDelayMs(\n attempt: number,\n policy: Required<Pick<RetryPolicy, 'initialDelayMs' | 'maxDelayMs'>>,\n): number {\n const base = policy.initialDelayMs * 2 ** attempt;\n const jitter = base * 0.25 * Math.random();\n return Math.min(base + jitter, policy.maxDelayMs);\n}\n\nexport function parseRetryAfter(\n headerValue: string | null,\n now: Date = new Date(),\n): Date | undefined {\n if (!headerValue) {\n return undefined;\n }\n const trimmed = headerValue.trim();\n if (/^\\d+$/.test(trimmed)) {\n return new Date(now.getTime() + Number(trimmed) * 1000);\n }\n const parsed = Date.parse(trimmed);\n if (Number.isNaN(parsed)) {\n return undefined;\n }\n return new Date(parsed);\n}\n\nexport function sleep(ms: number, signal?: AbortSignal): Promise<void> {\n if (signal?.aborted) {\n return Promise.reject(signal.reason ?? new Error('Aborted'));\n }\n return new Promise<void>((resolve, reject) => {\n const onAbort = () => {\n clearTimeout(timer);\n reject(signal!.reason ?? new Error('Aborted'));\n };\n const timer = setTimeout(() => {\n signal?.removeEventListener('abort', onAbort);\n resolve();\n }, ms);\n signal?.addEventListener('abort', onAbort, { once: true });\n });\n}\n","export const HTTP_CLIENT_VERSION = '0.0.0';\n\nexport const DEFAULT_USER_AGENT = `rawdash-connector/${HTTP_CLIENT_VERSION} (+https://rawdash.dev)`;\n\nexport function connectorUserAgent(connectorId: string): string {\n return `rawdash-connector-${connectorId}/${HTTP_CLIENT_VERSION} (+https://rawdash.dev)`;\n}\n","import {\n AuthError,\n ClientBugError,\n HttpClientError,\n RateLimitError,\n TransientError,\n UpstreamBugError,\n errorForStatus,\n} from './errors';\nimport { defaultRetryOn, parseRetryAfter, sleep } from './retry';\nimport type { FetchLike, HttpMethod, HttpRequest, HttpResponse } from './types';\nimport { DEFAULT_USER_AGENT } from './version';\n\nconst DEFAULT_TIMEOUT_MS = 10_000;\nconst DEFAULT_MAX_ATTEMPTS = 3;\nconst DEFAULT_INITIAL_DELAY_MS = 1000;\nconst DEFAULT_MAX_DELAY_MS = 60_000;\nconst OBSERVER_TIMEOUT_MS = 250;\n\nexport interface RequestObservation {\n url: string;\n method: HttpMethod;\n status: number;\n resource: string;\n requestId: string;\n body: unknown;\n}\n\nexport type RequestObserver = (\n event: RequestObservation,\n) => void | Promise<void>;\n\nexport interface RequestOptions {\n fetch?: FetchLike;\n observer?: RequestObserver;\n resource: string;\n requestId?: string;\n}\n\nasync function notifyObserver(\n observer: RequestObserver,\n event: RequestObservation,\n): Promise<void> {\n let result: void | Promise<void>;\n try {\n result = observer(event);\n } catch (err) {\n console.warn('[connector-shared] request observer threw:', err);\n return;\n }\n if (!(result instanceof Promise)) {\n return;\n }\n const guarded = result.catch((err) => {\n console.warn('[connector-shared] request observer rejected:', err);\n });\n let timer: ReturnType<typeof setTimeout> | undefined;\n const timeout = new Promise<void>((resolve) => {\n timer = setTimeout(resolve, OBSERVER_TIMEOUT_MS);\n });\n try {\n await Promise.race([guarded, timeout]);\n } finally {\n if (timer) {\n clearTimeout(timer);\n }\n }\n}\n\nfunction newRequestId(): string {\n const c = (globalThis as { crypto?: { randomUUID?: () => string } }).crypto;\n if (c?.randomUUID) {\n return c.randomUUID();\n }\n return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;\n}\n\nfunction mergeHeaders(\n defaults: Record<string, string>,\n overrides: Record<string, string> | undefined,\n): Record<string, string> {\n const merged: Record<string, string> = {};\n for (const [k, v] of Object.entries(defaults)) {\n merged[k.toLowerCase()] = v;\n }\n if (overrides) {\n for (const [k, v] of Object.entries(overrides)) {\n merged[k.toLowerCase()] = v;\n }\n }\n return merged;\n}\n\nfunction linkTimeoutSignal(\n parent: AbortSignal | undefined,\n timeoutMs: number,\n): { signal: AbortSignal; cancel: () => void } {\n const controller = new AbortController();\n const onParentAbort = () => {\n controller.abort(parent?.reason);\n };\n if (parent) {\n if (parent.aborted) {\n controller.abort(parent.reason);\n } else {\n parent.addEventListener('abort', onParentAbort, { once: true });\n }\n }\n const timer = setTimeout(() => {\n controller.abort(new Error(`Request timed out after ${timeoutMs}ms`));\n }, timeoutMs);\n return {\n signal: controller.signal,\n cancel: () => {\n clearTimeout(timer);\n if (parent) {\n parent.removeEventListener('abort', onParentAbort);\n }\n },\n };\n}\n\nasync function readBody(res: Response, parseJson: boolean): Promise<unknown> {\n if (res.status === 204 || res.status === 205) {\n return null;\n }\n const contentType = res.headers.get('content-type') ?? '';\n if (parseJson && contentType.includes('application/json')) {\n const text = await res.text();\n if (text.length === 0) {\n return null;\n }\n return JSON.parse(text);\n }\n return res.text();\n}\n\nexport async function request<T = unknown>(\n req: HttpRequest,\n options: RequestOptions,\n): Promise<HttpResponse<T>> {\n const fetchImpl: FetchLike = options.fetch ?? (globalThis.fetch as FetchLike);\n const retry = req.retry ?? {};\n const maxAttempts = retry.maxAttempts ?? DEFAULT_MAX_ATTEMPTS;\n const initialDelayMs = retry.initialDelayMs ?? DEFAULT_INITIAL_DELAY_MS;\n const maxDelayMs = retry.maxDelayMs ?? DEFAULT_MAX_DELAY_MS;\n const retryOn = retry.retryOn ?? defaultRetryOn;\n const timeoutMs = req.timeoutMs ?? DEFAULT_TIMEOUT_MS;\n const parseJson = req.parseJson ?? true;\n\n const headers = mergeHeaders(\n {\n 'User-Agent': DEFAULT_USER_AGENT,\n Accept: 'application/json',\n },\n req.headers,\n );\n\n let lastErr: Error | undefined;\n\n for (let attempt = 0; attempt < maxAttempts; attempt++) {\n req.signal?.throwIfAborted();\n\n const { signal, cancel } = linkTimeoutSignal(req.signal, timeoutMs);\n let res: Response;\n try {\n res = await fetchImpl(req.url, {\n method: req.method ?? 'GET',\n headers,\n body: req.body as RequestInit['body'],\n signal,\n });\n } catch (err) {\n cancel();\n if (req.signal?.aborted) {\n throw req.signal.reason ?? err;\n }\n const error = err instanceof Error ? err : new Error(String(err));\n lastErr = error;\n if (attempt < maxAttempts - 1 && retryOn(null, error)) {\n const delay = computeDelay(attempt, initialDelayMs, maxDelayMs);\n await sleep(delay, req.signal);\n continue;\n }\n throw new TransientError(error.message);\n }\n cancel();\n\n const body = await readBody(res, parseJson);\n const httpResponse: HttpResponse<T> = {\n status: res.status,\n headers: res.headers,\n body: body as T,\n };\n if (req.rateLimit) {\n const state = req.rateLimit.parse(res.headers);\n if (state) {\n httpResponse.rateLimitState = state;\n }\n }\n\n if (options.observer) {\n await notifyObserver(options.observer, {\n url: req.url,\n method: req.method ?? 'GET',\n status: res.status,\n resource: options.resource,\n requestId: options.requestId ?? newRequestId(),\n body,\n });\n }\n\n if (res.ok) {\n return httpResponse;\n }\n\n const retryAfter = parseRetryAfter(res.headers.get('retry-after'));\n const message = `HTTP ${res.status} ${res.statusText} for ${req.method ?? 'GET'} ${req.url}`;\n const err = errorForStatus(message, httpResponse, retryAfter);\n\n if (\n attempt < maxAttempts - 1 &&\n retryOn(res.status, err) &&\n !(err instanceof AuthError) &&\n !(err instanceof ClientBugError)\n ) {\n lastErr = err;\n let delay = computeDelay(attempt, initialDelayMs, maxDelayMs);\n if (err instanceof RateLimitError && retryAfter) {\n const wait = retryAfter.getTime() - Date.now();\n if (wait > 0) {\n delay = Math.min(wait, maxDelayMs);\n }\n }\n await sleep(delay, req.signal);\n continue;\n }\n\n throw err;\n }\n\n throw lastErr ?? new UpstreamBugError('Exhausted retry attempts');\n}\n\nfunction computeDelay(\n attempt: number,\n initialDelayMs: number,\n maxDelayMs: number,\n): number {\n const base = initialDelayMs * 2 ** attempt;\n const jitter = base * 0.25 * Math.random();\n return Math.min(base + jitter, maxDelayMs);\n}\n\nexport { HttpClientError };\n","export interface RateLimitState {\n remaining: number;\n resetAt: Date;\n}\n\nexport interface RateLimitPolicy {\n parse(headers: Headers): RateLimitState | null;\n}\n\nexport interface StandardRateLimitPolicyConfig {\n remainingHeader: string;\n resetHeader: string;\n resetUnit: 's' | 'ms';\n resetFallbackMs?: number;\n}\n\nexport function standardRateLimitPolicy(\n config: StandardRateLimitPolicyConfig,\n): RateLimitPolicy {\n const { remainingHeader, resetHeader, resetUnit, resetFallbackMs } = config;\n const multiplier = resetUnit === 's' ? 1000 : 1;\n return {\n parse(h) {\n const remainingRaw = h.get(remainingHeader);\n if (remainingRaw === null || remainingRaw.trim() === '') {\n return null;\n }\n const remaining = Number(remainingRaw);\n if (!Number.isFinite(remaining)) {\n return null;\n }\n const resetRaw = h.get(resetHeader);\n if (resetRaw === null) {\n if (resetFallbackMs === undefined) {\n return null;\n }\n return {\n remaining,\n resetAt: new Date(Date.now() + resetFallbackMs),\n };\n }\n if (resetRaw.trim() === '') {\n return null;\n }\n const reset = Number(resetRaw);\n if (!Number.isFinite(reset) || reset < 0) {\n return null;\n }\n const resetMs = reset * multiplier;\n if (!Number.isFinite(resetMs)) {\n return null;\n }\n return { remaining, resetAt: new Date(resetMs) };\n },\n };\n}\n","export async function mapWithConcurrency<T, R>(\n items: readonly T[],\n concurrency: number,\n fn: (item: T, index: number) => Promise<R>,\n): Promise<R[]> {\n const results = new Array<R>(items.length);\n if (items.length === 0) {\n return results;\n }\n const normalized = Number.isFinite(concurrency) ? Math.floor(concurrency) : 1;\n const limit = Math.max(1, Math.min(normalized, items.length));\n let next = 0;\n let failed = false;\n\n async function worker(): Promise<void> {\n while (!failed) {\n const i = next++;\n if (i >= items.length) {\n return;\n }\n try {\n results[i] = await fn(items[i]!, i);\n } catch (err) {\n failed = true;\n throw err;\n }\n }\n }\n\n const workers: Promise<void>[] = [];\n for (let w = 0; w < limit; w++) {\n workers.push(worker());\n }\n await Promise.all(workers);\n return results;\n}\n","export interface SanitizeAllowedUrlOptions {\n url: string | null;\n host: string;\n pathname: string;\n protocol?: 'https:' | 'http:';\n}\n\nexport function sanitizeAllowedUrl(\n options: SanitizeAllowedUrlOptions,\n): string | null {\n const { url, host, pathname, protocol = 'https:' } = options;\n if (url === null) {\n return null;\n }\n try {\n const u = new URL(url);\n if (u.protocol !== protocol || u.host !== host || u.pathname !== pathname) {\n return null;\n }\n return u.toString();\n } catch {\n return null;\n }\n}\n","export type EpochUnit = 'ms' | 's' | 'iso';\n\nexport function parseEpoch(\n value: number | string | null | undefined,\n unit: EpochUnit,\n): number | null {\n if (value === null || value === undefined) {\n return null;\n }\n if (unit === 'iso') {\n if (typeof value !== 'string') {\n return null;\n }\n const ms = new Date(value).getTime();\n return Number.isFinite(ms) ? ms : null;\n }\n if (typeof value === 'string' && value.trim() === '') {\n return null;\n }\n const n = typeof value === 'number' ? value : Number(value);\n if (!Number.isFinite(n)) {\n return null;\n }\n const result = unit === 's' ? n * 1000 : n;\n return Number.isFinite(result) ? result : null;\n}\n","import { request } from './request';\nimport type { HttpRequest } from './types';\n\nexport function parseLinkHeader(header: string | null): Record<string, string> {\n if (!header) {\n return {};\n }\n const result: Record<string, string> = {};\n for (const part of header.split(',')) {\n const match = part.match(/<([^>]+)>\\s*;\\s*rel=\"([^\"]+)\"/);\n if (match) {\n result[match[2]!] = match[1]!;\n }\n }\n return result;\n}\n\nexport async function* paginateLink<T>(\n initial: HttpRequest,\n parse: (body: unknown) => T[],\n options: { resource: string },\n): AsyncIterable<T> {\n let next: string | null = initial.url;\n while (next) {\n const res: Awaited<ReturnType<typeof request>> = await request(\n {\n ...initial,\n url: next,\n },\n { resource: options.resource },\n );\n for (const item of parse(res.body)) {\n yield item;\n }\n const links = parseLinkHeader(res.headers.get('link'));\n next = links['next'] ?? null;\n }\n}\n\nexport async function* paginateCursor<T>(\n initial: HttpRequest,\n parse: (body: unknown) => { items: T[]; nextCursor: string | null },\n buildNext: (req: HttpRequest, cursor: string) => HttpRequest,\n options: { resource: string },\n): AsyncIterable<T> {\n let req: HttpRequest = initial;\n while (true) {\n const res = await request(req, { resource: options.resource });\n const { items, nextCursor } = parse(res.body);\n for (const item of items) {\n yield item;\n }\n if (!nextCursor) {\n return;\n }\n req = buildNext(req, nextCursor);\n }\n}\n\nexport async function* paginatePage<T>(\n initial: HttpRequest,\n parse: (body: unknown) => { items: T[]; hasMore: boolean },\n buildPage: (req: HttpRequest, page: number) => HttpRequest,\n options: { resource: string },\n): AsyncIterable<T> {\n let page = 1;\n while (true) {\n const req = page === 1 ? initial : buildPage(initial, page);\n const res = await request(req, { resource: options.resource });\n const { items, hasMore } = parse(res.body);\n for (const item of items) {\n yield item;\n }\n if (!hasMore || items.length === 0) {\n return;\n }\n page++;\n }\n}\n","export type LogFields = Record<string, unknown>;\n\nexport interface ConnectorLogger {\n info(event: string, fields?: LogFields): void;\n warn(event: string, fields?: LogFields): void;\n}\n\nexport interface ConnectorLoggerOptions {\n scope: string;\n}\n\nconst MAX_VALUE_LEN = 120;\n\nfunction truncate(s: string, max = MAX_VALUE_LEN): string {\n if (s.length <= max) {\n return s;\n }\n return `${s.slice(0, max - 1)}…`;\n}\n\nfunction formatValue(value: unknown): string {\n if (value === null) {\n return 'null';\n }\n if (value === undefined) {\n return '';\n }\n if (typeof value === 'number' || typeof value === 'boolean') {\n return String(value);\n }\n if (typeof value === 'string') {\n const t = truncate(value);\n if (/[\\s\"=]/.test(t)) {\n return JSON.stringify(t);\n }\n return t;\n }\n if (typeof value === 'bigint') {\n return value.toString();\n }\n let json: string | undefined;\n try {\n json = JSON.stringify(value);\n } catch {\n json = undefined;\n }\n return truncate(json ?? String(value));\n}\n\nexport function formatLogFields(fields?: LogFields): string {\n if (!fields) {\n return '';\n }\n const parts: string[] = [];\n for (const [k, v] of Object.entries(fields)) {\n if (v === undefined) {\n continue;\n }\n parts.push(`${k}=${formatValue(v)}`);\n }\n return parts.length > 0 ? ` ${parts.join(' ')}` : '';\n}\n\nexport function formatLogLine(\n scope: string,\n event: string,\n fields?: LogFields,\n): string {\n return `[${scope}] ${event}${formatLogFields(fields)}`;\n}\n\nexport function createDefaultConnectorLogger(\n opts: ConnectorLoggerOptions,\n): ConnectorLogger {\n return {\n info(event, fields) {\n console.info(formatLogLine(opts.scope, event, fields));\n },\n warn(event, fields) {\n console.warn(formatLogLine(opts.scope, event, fields));\n },\n };\n}\n\nconst NOOP_LOGGER: ConnectorLogger = {\n info() {},\n warn() {},\n};\n\nexport function noopConnectorLogger(): ConnectorLogger {\n return NOOP_LOGGER;\n}\n","import {\n type HttpResponse,\n connectorUserAgent,\n parseEpoch,\n standardRateLimitPolicy,\n} from '@rawdash/connector-shared';\nimport {\n BaseConnector,\n type ChunkedSyncCursor,\n type ConnectorContext,\n type ConnectorDoc,\n type CredentialsSchema,\n type JSONValue,\n type StorageHandle,\n type SyncOptions,\n type SyncResult,\n defineConfigFields,\n defineConnectorDoc,\n defineResources,\n makeChunkedCursorGuard,\n paginateChunked,\n schemasFromResources,\n selectActivePhases,\n} from '@rawdash/core';\nimport { z } from 'zod';\n\nexport const configFields = defineConfigFields(\n z.object({\n apiKey: z.object({ $secret: z.string().min(1) }).meta({\n label: 'API key',\n description:\n 'SendGrid Web API v3 key with read access to the Stats and Suppressions APIs. Create one under Settings -> API Keys.',\n placeholder: 'SENDGRID_API_KEY',\n secret: true,\n }),\n categories: z.array(z.string().min(1)).nonempty().optional().meta({\n label: 'Categories',\n description:\n 'Optional list of SendGrid categories to break email stats down by. When set, daily stats are fetched per category from the Category Stats endpoint; when omitted, account-wide global stats are fetched and tagged with the category \"all\".',\n }),\n backfillDays: z.number().int().positive().optional().meta({\n label: 'Backfill window (days)',\n description:\n 'How many trailing days of email stats, bounces, and spam reports to pull on a full sync. Defaults to 90.',\n placeholder: '90',\n }),\n resources: z\n .array(z.enum(['email_stats', 'bounces', 'spam_reports']))\n .nonempty()\n .optional()\n .meta({\n label: 'Resources',\n description:\n 'Which SendGrid resources to sync. Omit to sync all of them.',\n }),\n }),\n);\n\nexport const doc: ConnectorDoc = defineConnectorDoc({\n displayName: 'SendGrid',\n category: 'marketing',\n brandColor: '#1A82E2',\n tagline:\n 'Sync daily SendGrid email stats (sends, delivery rate, bounce rate, spam complaints, opens, clicks) plus bounce and spam-report events for transactional-email dashboards.',\n vendor: {\n name: 'SendGrid',\n domain: 'sendgrid.com',\n apiDocs: 'https://docs.sendgrid.com/api-reference/',\n website: 'https://sendgrid.com',\n },\n auth: {\n summary: 'A SendGrid Web API v3 key sent as a bearer token.',\n setup: [\n 'In SendGrid, open Settings -> API Keys and create a new API key.',\n 'Grant it at least read access to Stats and Suppressions (Restricted Access -> Stats: Read, Suppressions: Read), or use a Full Access key.',\n 'Store the key as a rawdash secret and reference it from config as `apiKey: secret(\"SENDGRID_API_KEY\")`.',\n ],\n },\n rateLimit:\n 'SendGrid returns X-RateLimit-Remaining / X-RateLimit-Reset response headers; the shared HTTP client backs off on 429 using the standard rate-limit policy.',\n limitations: [\n 'Email stats are a daily aggregate series: each sync clears the metric scope and rewrites the requested window, so incremental syncs only refresh the trailing window (default 2 days) while full syncs repopulate the whole backfill window.',\n 'Category-level stats require the categories to be listed in config; SendGrid has no \"all categories\" stats call.',\n 'Bounce and spam-report events are read from the Suppressions API and are limited to addresses still present in the suppression lists; entries removed from SendGrid are not retained.',\n ],\n});\n\nexport type SendgridResource = 'email_stats' | 'bounces' | 'spam_reports';\n\nexport interface SendgridSettings {\n categories?: readonly string[];\n backfillDays?: number;\n resources?: readonly SendgridResource[];\n}\n\nconst sendgridCredentials = {\n apiKey: {\n description: 'SendGrid Web API v3 key',\n auth: 'required' as const,\n },\n} satisfies CredentialsSchema;\n\ntype SendgridCredentials = typeof sendgridCredentials;\n\nconst sendgridRateLimit = standardRateLimitPolicy({\n remainingHeader: 'x-ratelimit-remaining',\n resetHeader: 'x-ratelimit-reset',\n resetUnit: 's',\n});\n\nconst PHASE_ORDER = ['email_stats', 'bounces', 'spam_reports'] as const;\n\ntype SendgridPhase = (typeof PHASE_ORDER)[number];\n\ntype SendgridSyncCursor = ChunkedSyncCursor<SendgridPhase, string>;\n\nconst isSendgridSyncCursor = makeChunkedCursorGuard(PHASE_ORDER);\n\nconst EMAIL_STATS_METRIC = 'sendgrid_email_stats';\nconst BOUNCE_EVENT = 'sendgrid_bounce';\nconst SPAM_REPORT_EVENT = 'sendgrid_spam_report';\n\nconst BASE_URL = 'https://api.sendgrid.com/v3';\nconst SUPPRESSION_PAGE_SIZE = 500;\nconst MS_PER_DAY = 24 * 60 * 60 * 1000;\nconst DEFAULT_BACKFILL_DAYS = 90;\nconst INCREMENTAL_LOOKBACK_DAYS = 2;\nconst GLOBAL_CATEGORY = 'all';\n\nconst statMetricsSchema = z.object({\n blocks: z.number().nullish(),\n bounce_drops: z.number().nullish(),\n bounces: z.number().nullish(),\n clicks: z.number().nullish(),\n deferred: z.number().nullish(),\n delivered: z.number().nullish(),\n invalid_emails: z.number().nullish(),\n opens: z.number().nullish(),\n processed: z.number().nullish(),\n requests: z.number().nullish(),\n spam_report_drops: z.number().nullish(),\n spam_reports: z.number().nullish(),\n unique_clicks: z.number().nullish(),\n unique_opens: z.number().nullish(),\n unsubscribe_drops: z.number().nullish(),\n unsubscribes: z.number().nullish(),\n});\n\nconst statEntrySchema = z.object({\n type: z.string().nullish(),\n name: z.string().nullish(),\n metrics: statMetricsSchema.nullish(),\n});\n\nconst statsDaySchema = z.object({\n date: z.string(),\n stats: z.array(statEntrySchema).nullish(),\n});\n\nconst statsResponseSchema = z.array(statsDaySchema);\n\nconst bounceSchema = z.object({\n created: z.number(),\n email: z.string(),\n reason: z.string().nullish(),\n status: z.string().nullish(),\n});\n\nconst bouncesResponseSchema = z.array(bounceSchema);\n\nconst spamReportSchema = z.object({\n created: z.number(),\n email: z.string(),\n ip: z.string().nullish(),\n});\n\nconst spamReportsResponseSchema = z.array(spamReportSchema);\n\nexport const sendgridResources = defineResources({\n [EMAIL_STATS_METRIC]: {\n shape: 'metric',\n description:\n 'Daily email engagement stats (requests, delivered, bounces, spam reports, opens, clicks, unsubscribes) from the SendGrid Stats API, one sample per (day, category). The sample value is the number of requests (sends); every other counter is exposed as an attribute.',\n endpoint: 'GET /stats',\n unit: 'emails',\n granularity: '1d',\n notes:\n 'Aggregated by day. The metric scope is cleared and rewritten on every sync because aggregate daily stats cannot be upserted by key. When categories are configured the Category Stats endpoint (GET /categories/stats) is used instead and the category dimension carries the category name.',\n dimensions: [\n {\n name: 'category',\n description:\n 'SendGrid category, or \"all\" for account-wide global stats.',\n },\n { name: 'requests', description: 'Emails requested (sends).' },\n { name: 'delivered', description: 'Emails delivered.' },\n { name: 'bounces', description: 'Bounced emails.' },\n {\n name: 'bounceDrops',\n description: 'Emails dropped due to prior bounces.',\n },\n { name: 'blocks', description: 'Blocked emails.' },\n { name: 'deferred', description: 'Temporarily deferred emails.' },\n { name: 'invalidEmails', description: 'Invalid recipient addresses.' },\n { name: 'processed', description: 'Emails processed.' },\n { name: 'opens', description: 'Total opens.' },\n { name: 'uniqueOpens', description: 'Unique opens.' },\n { name: 'clicks', description: 'Total clicks.' },\n { name: 'uniqueClicks', description: 'Unique clicks.' },\n { name: 'spamReports', description: 'Spam complaints.' },\n {\n name: 'spamReportDrops',\n description: 'Emails dropped due to prior spam reports.',\n },\n { name: 'unsubscribes', description: 'Unsubscribes.' },\n {\n name: 'unsubscribeDrops',\n description: 'Emails dropped due to prior unsubscribes.',\n },\n ],\n responses: { email_stats: statsResponseSchema },\n },\n [BOUNCE_EVENT]: {\n shape: 'event',\n filterable: [],\n description:\n 'Bounce events from the SendGrid Suppressions API. One event per bounced address, timestamped at the bounce time.',\n endpoint: 'GET /suppression/bounces',\n notes:\n 'Paginated via limit / offset over the [start_time, end_time] window. Incremental syncs pull from the last sync time forward.',\n fields: [\n { name: 'email', description: 'Recipient address that bounced.' },\n {\n name: 'reason',\n description: 'Reason reported by the receiving server.',\n },\n { name: 'status', description: 'SMTP status code for the bounce.' },\n ],\n responses: { bounces: bouncesResponseSchema },\n },\n [SPAM_REPORT_EVENT]: {\n shape: 'event',\n filterable: [],\n description:\n 'Spam-report (complaint) events from the SendGrid Suppressions API. One event per complaining address, timestamped at the report time.',\n endpoint: 'GET /suppression/spam_reports',\n notes:\n 'Paginated via limit / offset over the [start_time, end_time] window. Incremental syncs pull from the last sync time forward.',\n fields: [\n { name: 'email', description: 'Recipient address that reported spam.' },\n {\n name: 'ip',\n description: 'Sending IP the complaint was attributed to.',\n },\n ],\n responses: { spam_reports: spamReportsResponseSchema },\n },\n});\n\nexport const id = 'sendgrid';\n\ntype StatsResponse = z.infer<typeof statsResponseSchema>;\ntype StatsDay = z.infer<typeof statsDaySchema>;\ntype Bounce = z.infer<typeof bounceSchema>;\ntype SpamReport = z.infer<typeof spamReportSchema>;\n\ninterface StatsDateRange {\n startDate: string;\n endDate: string;\n}\n\ninterface SuppressionWindow {\n startTime: number;\n endTime: number;\n}\n\nfunction toYmd(ms: number): string {\n const d = new Date(ms);\n const y = d.getUTCFullYear();\n const m = String(d.getUTCMonth() + 1).padStart(2, '0');\n const day = String(d.getUTCDate()).padStart(2, '0');\n return `${y}-${m}-${day}`;\n}\n\nfunction ymdToMs(value: string): number | null {\n const m = /^(\\d{4})-(\\d{2})-(\\d{2})/.exec(value);\n if (!m) {\n return null;\n }\n const ms = Date.UTC(Number(m[1]), Number(m[2]) - 1, Number(m[3]));\n return Number.isFinite(ms) ? ms : null;\n}\n\nfunction counterValue(value: number | null | undefined): number {\n return typeof value === 'number' && Number.isFinite(value) ? value : 0;\n}\n\nfunction offsetFromPage(page: string | null): number {\n if (page === null) {\n return 0;\n }\n const n = Number(page);\n return Number.isFinite(n) && n >= 0 ? n : 0;\n}\n\nexport class SendgridConnector extends BaseConnector<\n SendgridSettings,\n SendgridCredentials\n> {\n static readonly id = id;\n\n static readonly resources = sendgridResources;\n\n static readonly schemas = schemasFromResources(sendgridResources);\n\n static create(input: unknown, ctx?: ConnectorContext): SendgridConnector {\n const parsed = configFields.parse(input);\n return new SendgridConnector(\n {\n categories: parsed.categories,\n backfillDays: parsed.backfillDays,\n resources: parsed.resources,\n },\n { apiKey: parsed.apiKey },\n ctx,\n );\n }\n\n readonly id = id;\n override readonly credentials = sendgridCredentials;\n\n private get backfillDays(): number {\n return this.settings.backfillDays ?? DEFAULT_BACKFILL_DAYS;\n }\n\n private buildHeaders(): Record<string, string> {\n return {\n Authorization: `Bearer ${this.creds.apiKey}`,\n Accept: 'application/json',\n 'User-Agent': connectorUserAgent('sendgrid'),\n };\n }\n\n private async apiGet<T>(\n url: string,\n resource: string,\n signal?: AbortSignal,\n ): Promise<HttpResponse<T>> {\n return this.get<T>(url, {\n resource,\n headers: this.buildHeaders(),\n rateLimit: sendgridRateLimit,\n signal,\n });\n }\n\n private statsDateRange(options: SyncOptions): StatsDateRange {\n const now = Date.now();\n const endDate = toYmd(now);\n if (options.mode === 'latest') {\n const startMs = now - (INCREMENTAL_LOOKBACK_DAYS - 1) * MS_PER_DAY;\n return { startDate: toYmd(startMs), endDate };\n }\n if (options.since) {\n const sinceMs = Date.parse(options.since);\n if (Number.isFinite(sinceMs)) {\n const days = Math.max(1, Math.ceil((now - sinceMs) / MS_PER_DAY));\n const capped = Math.min(days, this.backfillDays);\n const startMs = now - (capped - 1) * MS_PER_DAY;\n return { startDate: toYmd(startMs), endDate };\n }\n }\n const startMs = now - (this.backfillDays - 1) * MS_PER_DAY;\n return { startDate: toYmd(startMs), endDate };\n }\n\n private suppressionWindow(options: SyncOptions): SuppressionWindow {\n const nowSec = Math.floor(Date.now() / 1000);\n if (options.mode !== 'full' && options.since) {\n const sinceMs = Date.parse(options.since);\n if (Number.isFinite(sinceMs)) {\n return { startTime: Math.floor(sinceMs / 1000), endTime: nowSec };\n }\n }\n return {\n startTime: nowSec - this.backfillDays * 24 * 60 * 60,\n endTime: nowSec,\n };\n }\n\n private buildStatsUrl(range: StatsDateRange): string {\n const useCategories =\n this.settings.categories && this.settings.categories.length > 0;\n const u = new URL(\n `${BASE_URL}${useCategories ? '/categories/stats' : '/stats'}`,\n );\n u.searchParams.set('start_date', range.startDate);\n u.searchParams.set('end_date', range.endDate);\n u.searchParams.set('aggregated_by', 'day');\n if (useCategories) {\n for (const category of this.settings.categories!) {\n u.searchParams.append('categories', category);\n }\n }\n return u.toString();\n }\n\n private buildSuppressionUrl(\n path: string,\n window: SuppressionWindow,\n offset: number,\n ): string {\n const u = new URL(`${BASE_URL}${path}`);\n u.searchParams.set('start_time', String(window.startTime));\n u.searchParams.set('end_time', String(window.endTime));\n u.searchParams.set('limit', String(SUPPRESSION_PAGE_SIZE));\n u.searchParams.set('offset', String(offset));\n return u.toString();\n }\n\n private async fetchStats(\n page: string | null,\n options: SyncOptions,\n signal?: AbortSignal,\n ): Promise<{ items: StatsDay[]; next: string | null }> {\n if (page !== null) {\n return { items: [], next: null };\n }\n const url = this.buildStatsUrl(this.statsDateRange(options));\n const res = await this.apiGet<StatsResponse>(url, 'email_stats', signal);\n return { items: res.body, next: null };\n }\n\n private async fetchSuppressionPage<T>(\n path: string,\n resource: string,\n page: string | null,\n window: SuppressionWindow,\n signal?: AbortSignal,\n ): Promise<{ items: T[]; next: string | null }> {\n const offset = offsetFromPage(page);\n const url = this.buildSuppressionUrl(path, window, offset);\n const res = await this.apiGet<T[]>(url, resource, signal);\n const items = res.body;\n const next =\n items.length < SUPPRESSION_PAGE_SIZE\n ? null\n : String(offset + SUPPRESSION_PAGE_SIZE);\n return { items, next };\n }\n\n private async writeEmailStats(\n storage: StorageHandle,\n items: StatsDay[],\n ): Promise<void> {\n const samples: Array<{\n name: string;\n ts: number;\n value: number;\n attributes: Record<string, JSONValue>;\n }> = [];\n for (const day of items) {\n const ts = ymdToMs(day.date);\n if (ts === null) {\n continue;\n }\n for (const entry of day.stats ?? []) {\n const metrics = entry.metrics ?? {};\n const category = entry.name ?? GLOBAL_CATEGORY;\n samples.push({\n name: EMAIL_STATS_METRIC,\n ts,\n value: counterValue(metrics.requests),\n attributes: {\n category,\n requests: counterValue(metrics.requests),\n delivered: counterValue(metrics.delivered),\n bounces: counterValue(metrics.bounces),\n bounceDrops: counterValue(metrics.bounce_drops),\n blocks: counterValue(metrics.blocks),\n deferred: counterValue(metrics.deferred),\n invalidEmails: counterValue(metrics.invalid_emails),\n processed: counterValue(metrics.processed),\n opens: counterValue(metrics.opens),\n uniqueOpens: counterValue(metrics.unique_opens),\n clicks: counterValue(metrics.clicks),\n uniqueClicks: counterValue(metrics.unique_clicks),\n spamReports: counterValue(metrics.spam_reports),\n spamReportDrops: counterValue(metrics.spam_report_drops),\n unsubscribes: counterValue(metrics.unsubscribes),\n unsubscribeDrops: counterValue(metrics.unsubscribe_drops),\n },\n });\n }\n }\n await storage.metrics(samples, { names: [EMAIL_STATS_METRIC] });\n }\n\n private async writeBounces(\n storage: StorageHandle,\n items: Bounce[],\n ): Promise<void> {\n for (const bounce of items) {\n const ts = parseEpoch(bounce.created, 's');\n if (ts === null) {\n continue;\n }\n await storage.event({\n name: BOUNCE_EVENT,\n start_ts: ts,\n end_ts: null,\n attributes: {\n email: bounce.email,\n reason: bounce.reason ?? null,\n status: bounce.status ?? null,\n },\n });\n }\n }\n\n private async writeSpamReports(\n storage: StorageHandle,\n items: SpamReport[],\n ): Promise<void> {\n for (const report of items) {\n const ts = parseEpoch(report.created, 's');\n if (ts === null) {\n continue;\n }\n await storage.event({\n name: SPAM_REPORT_EVENT,\n start_ts: ts,\n end_ts: null,\n attributes: {\n email: report.email,\n ip: report.ip ?? null,\n },\n });\n }\n }\n\n private async clearScopeOnFirstPage(\n storage: StorageHandle,\n phase: SendgridPhase,\n isFull: boolean,\n ): Promise<void> {\n switch (phase) {\n case 'email_stats':\n return;\n case 'bounces':\n if (isFull) {\n await storage.events([], { names: [BOUNCE_EVENT] });\n }\n return;\n case 'spam_reports':\n if (isFull) {\n await storage.events([], { names: [SPAM_REPORT_EVENT] });\n }\n return;\n }\n }\n\n private async writePhase(\n storage: StorageHandle,\n phase: SendgridPhase,\n items: unknown[],\n ): Promise<void> {\n switch (phase) {\n case 'email_stats':\n return this.writeEmailStats(storage, items as StatsDay[]);\n case 'bounces':\n return this.writeBounces(storage, items as Bounce[]);\n case 'spam_reports':\n return this.writeSpamReports(storage, items as SpamReport[]);\n }\n }\n\n async sync(\n options: SyncOptions,\n storage: StorageHandle,\n signal?: AbortSignal,\n ): Promise<SyncResult> {\n const cursor: SendgridSyncCursor | undefined = isSendgridSyncCursor(\n options.cursor,\n )\n ? options.cursor\n : undefined;\n const isFull = options.mode === 'full';\n\n const phases = selectActivePhases<SendgridResource, SendgridPhase>(\n (r) => r,\n PHASE_ORDER,\n this.settings.resources,\n );\n\n const window = this.suppressionWindow(options);\n\n return paginateChunked<SendgridPhase, string>({\n phases,\n cursor,\n signal,\n logger: this.logger,\n fetchPage: async (phase, page, sig) => {\n switch (phase) {\n case 'email_stats':\n return this.fetchStats(page, options, sig);\n case 'bounces':\n return this.fetchSuppressionPage<Bounce>(\n '/suppression/bounces',\n 'bounces',\n page,\n window,\n sig,\n );\n case 'spam_reports':\n return this.fetchSuppressionPage<SpamReport>(\n '/suppression/spam_reports',\n 'spam_reports',\n page,\n window,\n sig,\n );\n }\n },\n writeBatch: async (phase, items, page) => {\n if (page === null) {\n await this.clearScopeOnFirstPage(storage, phase, isFull);\n }\n await this.writePhase(storage, phase, items);\n },\n });\n }\n}\n","import { SendgridConnector } from './sendgrid';\n\nexport {\n SendgridConnector,\n configFields,\n doc,\n id,\n sendgridResources as resources,\n} from './sendgrid';\nexport type { SendgridResource, SendgridSettings } from './sendgrid';\nexport default SendgridConnector;\n"],"mappings":"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","names":["startMs"]}
package/package.json ADDED
@@ -0,0 +1,43 @@
1
+ {
2
+ "name": "@rawdash/connector-sendgrid",
3
+ "version": "0.28.0",
4
+ "description": "Rawdash connector for SendGrid — daily email stats (sends, delivered, bounces, spam reports, opens, clicks) plus bounce and spam-report events from the SendGrid Web API v3",
5
+ "license": "Apache-2.0",
6
+ "type": "module",
7
+ "sideEffects": false,
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "https://github.com/rawdash/rawdash.git",
11
+ "directory": "packages/connectors/sendgrid"
12
+ },
13
+ "files": [
14
+ "dist",
15
+ "README.md",
16
+ "LICENSE"
17
+ ],
18
+ "exports": {
19
+ ".": {
20
+ "@rawdash/source": "./src/index.ts",
21
+ "types": "./dist/index.d.ts",
22
+ "import": "./dist/index.js"
23
+ }
24
+ },
25
+ "scripts": {
26
+ "build": "tsup",
27
+ "typecheck": "tsc --noEmit",
28
+ "lint": "eslint src",
29
+ "test": "vitest run"
30
+ },
31
+ "dependencies": {
32
+ "@rawdash/core": "workspace:*",
33
+ "zod": "^4.4.3"
34
+ },
35
+ "devDependencies": {
36
+ "@rawdash/connector-shared": "workspace:*",
37
+ "@rawdash/connector-test-utils": "workspace:*",
38
+ "fast-check": "^4.8.0",
39
+ "tsup": "^8.0.0",
40
+ "typescript": "^5.7.2",
41
+ "vitest": "^4.1.4"
42
+ }
43
+ }