{"_id":"@cslegany/strapi5-sendgrid-tools","name":"@cslegany/strapi5-sendgrid-tools","dist-tags":{"latest":"0.0.1"},"versions":{"0.0.1":{"name":"@cslegany/strapi5-sendgrid-tools","version":"0.0.1","description":"SendGrid sync integration with Strapi5","keywords":[],"homepage":"https://github.com/cslegany-synerinsoft/strapi5-sendgrid-tools#readme","bugs":{"url":"https://github.com/cslegany-synerinsoft/strapi5-sendgrid-tools/issues"},"repository":{"type":"git","url":"git+https://github.com/cslegany-synerinsoft/strapi5-sendgrid-tools.git"},"license":"MIT","author":{"name":"cslegany","email":"cslegany@synerinsoft.com","url":"https://synerinsoft.com/"},"maintainers":[{"name":"cslegany","email":"cslegany@synerinsoft.com"}],"type":"commonjs","exports":{"./package.json":"./package.json","./strapi-admin":{"types":"./dist/admin/src/index.d.ts","source":"./admin/src/index.ts","import":"./dist/admin/index.mjs","require":"./dist/admin/index.js","default":"./dist/admin/index.js"},"./strapi-server":{"types":"./dist/server/src/index.d.ts","source":"./server/src/index.ts","import":"./dist/server/index.mjs","require":"./dist/server/index.js","default":"./dist/server/index.js"}},"main":"index.js","scripts":{"build":"strapi-plugin build","test:ts:front":"run -T tsc -p admin/tsconfig.json","test:ts:back":"run -T tsc -p server/tsconfig.json","verify":"strapi-plugin verify","watch":"strapi-plugin watch","watch:link":"strapi-plugin watch:link"},"dependencies":{"@strapi/design-system":"^2.0.0-rc.11","@strapi/icons":"^2.0.0-rc.11","crypto-js":"^4.2.0","moment":"^2.30.1","react-intl":"^6.8.0"},"devDependencies":{"@strapi/sdk-plugin":"^5.2.7","@strapi/strapi":"^5.0.0","@strapi/typescript-utils":"^5.0.0","@types/crypto-js":"^4.2.2","@types/react":"^18.3.11","@types/react-dom":"^18.3.1","prettier":"^3.3.3","react":"^18.3.1","react-dom":"^18.3.1","react-router-dom":"^6.27.0","styled-components":"^6.1.13","typescript":"^5.6.3"},"peerDependencies":{"@strapi/sdk-plugin":"^5.2.7","@strapi/strapi":"^5.0.0","react":"^18.3.1","react-dom":"^18.3.1","react-router-dom":"^6.27.0","styled-components":"^6.1.13"},"strapi":{"kind":"plugin","name":"sendgrid-tools","displayName":"","description":""},"gitHead":"ef0c4c283df4eb60214f9445ec157aa97ec9e38a","_id":"@cslegany/strapi5-sendgrid-tools@0.0.1","_nodeVersion":"24.13.0","_npmVersion":"11.6.2","dist":{"integrity":"sha512-2LXWuYQFjox/g9QO0VaWmTF+Nc2rOf8sw5ROEFIwHHAyxNBpnc617MOcOY3CzNu+wnJvoiygrmAcKYMVVj+Jlw==","shasum":"f1c7b28e50b5092cec9a8b6e754da27bb18158c7","tarball":"https://registry.npmjs.org/@cslegany/strapi5-sendgrid-tools/-/strapi5-sendgrid-tools-0.0.1.tgz","fileCount":12,"unpackedSize":312651,"signatures":[{"keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U","sig":"MEUCIQDhYLaDTsCPCqcvjg5uJEdUoSDA4PRl69ZPocJ4aIeAEQIgCwuMEJ5tlHkWbS69X9GSgoki1bYh6Vsl9afrgwVHV/k="}]},"_npmUser":{"name":"cslegany","email":"cslegany@synerinsoft.com"},"directories":{},"_npmOperationalInternal":{"host":"s3://npm-registry-packages-npm-production","tmp":"tmp/strapi5-sendgrid-tools_0.0.1_1775320116969_0.015274601271711763"},"_hasShrinkwrap":false}},"time":{"created":"2026-04-04T16:28:36.882Z","0.0.1":"2026-04-04T16:28:37.108Z","modified":"2026-04-04T16:28:37.675Z"},"maintainers":[{"name":"cslegany","email":"cslegany@synerinsoft.com"}],"description":"SendGrid sync integration with Strapi5","homepage":"https://github.com/cslegany-synerinsoft/strapi5-sendgrid-tools#readme","keywords":[],"repository":{"type":"git","url":"git+https://github.com/cslegany-synerinsoft/strapi5-sendgrid-tools.git"},"author":{"name":"cslegany","email":"cslegany@synerinsoft.com","url":"https://synerinsoft.com/"},"bugs":{"url":"https://github.com/cslegany-synerinsoft/strapi5-sendgrid-tools/issues"},"license":"MIT","readme":"# sendgrid-tools\r\n\r\nUse it with the following entity in your strapi project: subscription.\r\n\r\nschema.json:\r\n{\r\n  \"kind\": \"collectionType\",\r\n  \"collectionName\": \"subscriptions\",\r\n  \"info\": {\r\n    \"singularName\": \"subscription\",\r\n    \"pluralName\": \"subscriptions\",\r\n    \"displayName\": \"Subscription\",\r\n    \"description\": \"\"\r\n  },\r\n  \"options\": {\r\n    \"draftAndPublish\": false\r\n  },\r\n  \"pluginOptions\": {},\r\n  \"attributes\": {\r\n    \"title\": {\r\n      \"type\": \"string\"\r\n    },\r\n    \"email\": {\r\n      \"type\": \"email\",\r\n      \"required\": true,\r\n      \"unique\": true\r\n    },\r\n    \"first_name\": {\r\n      \"type\": \"string\",\r\n      \"required\": true\r\n    },\r\n    \"last_name\": {\r\n      \"type\": \"string\",\r\n      \"required\": true\r\n    },\r\n    \"subscription_status\": {\r\n      \"type\": \"enumeration\",\r\n      \"enum\": [\r\n        \"subscribed\",\r\n        \"unsubscribed\"\r\n      ],\r\n      \"default\": \"subscribed\",\r\n      \"required\": true\r\n    },\r\n    \"unsubscribed_at\": {\r\n      \"type\": \"datetime\"\r\n    },\r\n    \"resubscribed_at\": {\r\n      \"type\": \"datetime\"\r\n    },\r\n    \"unsubscribe_token\": {\r\n      \"type\": \"string\",\r\n      \"unique\": true\r\n    }\r\n  }\r\n}\r\n\r\ncontrollers/subscription.ts:\r\nimport { factories } from '@strapi/strapi';\r\n\r\nexport default factories.createCoreController('api::subscription.subscription', ({ strapi }) => ({\r\n\tasync unsubscribe(ctx) {\r\n\t\tconst token = ctx.request.body?.token;\r\n\r\n\t\tif (!token) {\r\n\t\t\treturn ctx.badRequest('Missing token');\r\n\t\t}\r\n\r\n\t\tconst result = await strapi\r\n\t\t\t.service('api::subscription.subscription')\r\n\t\t\t.unsubscribeByToken(token);\r\n\r\n\t\tif (!result.ok && result.code === 'invalid-token') {\r\n\t\t\treturn ctx.notFound('Invalid token');\r\n\t\t}\r\n\r\n\t\treturn ctx.send(result);\r\n\t},\r\n\r\n\tasync resyncSendgrid(ctx) {\r\n\t\tconst internalToken = ctx.request.header.authorization?.replace('Bearer ', '');\r\n\t\tconst expectedToken = process.env.INTERNAL_SYNC_TOKEN;\r\n\r\n\t\tif (!expectedToken || internalToken !== expectedToken) {\r\n\t\t\treturn ctx.unauthorized('Invalid token');\r\n\t\t}\r\n\r\n\t\tconst result = await strapi\r\n\t\t\t.service('api::subscription.subscription')\r\n\t\t\t.resyncAllSubscribedToSendgrid();\r\n\r\n\t\treturn ctx.send(result);\r\n\t},\r\n\r\n\tasync getSendgridCustomFields(ctx) {\r\n\t\tconst internalToken = ctx.request.header.authorization?.replace('Bearer ', '');\r\n\t\tconst expectedToken = process.env.INTERNAL_SYNC_TOKEN;\r\n\t\tconst isDev = process.env.NODE_ENV === 'development';\r\n\t\tconst apiKey = process.env.SENDGRID_API_KEY;\r\n\r\n\t\tif (!isDev && (!expectedToken || internalToken !== expectedToken)) {\r\n\t\t\treturn ctx.unauthorized('Invalid token');\r\n\t\t}\r\n\r\n\t\tif (!apiKey) {\r\n\t\t\treturn ctx.internalServerError('Missing SENDGRID_API_KEY');\r\n\t\t}\r\n\r\n\t\tconst res = await fetch('https://api.sendgrid.com/v3/marketing/field_definitions', {\r\n\t\t\tmethod: 'GET',\r\n\t\t\theaders: {\r\n\t\t\t\tAuthorization: `Bearer ${apiKey}`,\r\n\t\t\t\t'Content-Type': 'application/json',\r\n\t\t\t},\r\n\t\t});\r\n\r\n\t\tif (!res.ok) {\r\n\t\t\tconst text = await res.text();\r\n\t\t\tstrapi.log.error(`SendGrid field definitions failed: ${res.status} ${text}`);\r\n\t\t\treturn ctx.internalServerError(`SendGrid field definitions failed: ${res.status}`);\r\n\t\t}\r\n\r\n\t\tconst json = await res.json();\r\n\t\treturn ctx.send(json);\r\n\t},\r\n}));\r\n\r\nroutes/subscription.ts:\r\nexport default {\r\n  routes: [\r\n    {\r\n      method: 'POST',\r\n      path: '/subscriptions/resync-sendgrid',\r\n      handler: 'subscription.resyncSendgrid',\r\n      config: {\r\n        auth: false,\r\n      },\r\n    },\r\n    {\r\n      method: 'GET',\r\n      path: '/subscriptions/sendgrid-custom-fields',\r\n      handler: 'subscription.getSendgridCustomFields',\r\n      config: {\r\n        auth: false,\r\n      },\r\n    },\r\n    {\r\n      method: 'POST',\r\n      path: '/subscriptions/unsubscribe',\r\n      handler: 'subscription.unsubscribe',\r\n      config: {\r\n        auth: false,\r\n      },\r\n    },\r\n  ],\r\n};\r\n\r\nservices/subscription.ts:\r\nimport { factories } from '@strapi/strapi';\r\nimport crypto from 'crypto';\r\n\r\ntype SendgridToolsSettings = {\r\n\tenabled: boolean;\r\n\tsyncUnsubscribed: boolean;\r\n};\r\n\r\nasync function getSendgridToolsSettings(strapi: any): Promise<SendgridToolsSettings> {\r\n\tconst settings = await strapi\r\n\t\t.plugin('sendgrid-tools')\r\n\t\t.service('settings')\r\n\t\t.getSettings();\r\n\r\n\treturn {\r\n\t\tenabled: settings?.enabled ?? true,\r\n\t\tsyncUnsubscribed: settings?.syncUnsubscribed ?? false,\r\n\t};\r\n}\r\n\r\ntype SubscriptionInput = {\r\n\temail: string;\r\n\tfirst_name: string;\r\n\tlast_name: string;\r\n};\r\n\r\ntype SendgridContactInput = {\r\n\temail: string;\r\n\tfirst_name: string;\r\n\tlast_name: string;\r\n\tunsubscribe_url: string;\r\n};\r\n\r\ntype SendgridSuppressionMode =\r\n\t| { type: 'none' }\r\n\t| { type: 'global' }\r\n\t| { type: 'group'; groupId: string };\r\n\r\ntype SubscriptionStatus = 'subscribed' | 'unsubscribed';\r\n\r\nfunction normalizeInput(data: SubscriptionInput): SubscriptionInput {\r\n\treturn {\r\n\t\temail: data.email.trim().toLowerCase(),\r\n\t\tfirst_name: data.first_name.trim(),\r\n\t\tlast_name: data.last_name.trim(),\r\n\t};\r\n}\r\n\r\nfunction buildTitle(data: SubscriptionInput) {\r\n\treturn `${data.last_name.trim()} ${data.first_name.trim()} <${data.email.trim().toLowerCase()}>`;\r\n}\r\n\r\nfunction buildUnsubscribeUrl(token: string) {\r\n\tconst frontendUrl = process.env.FRONTEND_URL;\r\n\r\n\tif (!frontendUrl) {\r\n\t\tthrow new Error('Missing FRONTEND_URL');\r\n\t}\r\n\r\n\treturn `${frontendUrl.replace(/\\/$/, '')}/hirlevel/leiratkozas?token=${encodeURIComponent(token)}`;\r\n}\r\n\r\nfunction getErrorDetails(err: unknown) {\r\n\tif (err instanceof Error) {\r\n\t\tconst cause = (err as Error & { cause?: unknown }).cause;\r\n\r\n\t\tif (cause instanceof Error) {\r\n\t\t\treturn `${err.name}: ${err.message} | cause: ${cause.name}: ${cause.message}`;\r\n\t\t}\r\n\r\n\t\tif (cause) {\r\n\t\t\ttry {\r\n\t\t\t\treturn `${err.name}: ${err.message} | cause: ${JSON.stringify(cause)}`;\r\n\t\t\t} catch {\r\n\t\t\t\treturn `${err.name}: ${err.message} | cause: ${String(cause)}`;\r\n\t\t\t}\r\n\t\t}\r\n\r\n\t\treturn `${err.name}: ${err.message}`;\r\n\t}\r\n\r\n\ttry {\r\n\t\treturn JSON.stringify(err);\r\n\t} catch {\r\n\t\treturn String(err);\r\n\t}\r\n}\r\n\r\nfunction sleep(ms: number) {\r\n\treturn new Promise((resolve) => setTimeout(resolve, ms));\r\n}\r\n\r\nasync function fetchWithTimeout(url: string, options: RequestInit = {}, timeoutMs = 10000) {\r\n\tconst controller = new AbortController();\r\n\tconst timeout = setTimeout(() => controller.abort(), timeoutMs);\r\n\r\n\ttry {\r\n\t\treturn await fetch(url, {\r\n\t\t\t...options,\r\n\t\t\tsignal: controller.signal,\r\n\t\t});\r\n\t} finally {\r\n\t\tclearTimeout(timeout);\r\n\t}\r\n}\r\n\r\nasync function fetchWithRetry(\r\n\turl: string,\r\n\toptions: RequestInit,\r\n\t{\r\n\t\tretries = 3,\r\n\t\ttimeoutMs = 10000,\r\n\t}: { retries?: number; timeoutMs?: number } = {}\r\n) {\r\n\tlet lastError: unknown;\r\n\r\n\tfor (let attempt = 1; attempt <= retries; attempt++) {\r\n\t\ttry {\r\n\t\t\tconst res = await fetchWithTimeout(url, options, timeoutMs);\r\n\r\n\t\t\tif (res.ok) {\r\n\t\t\t\treturn res;\r\n\t\t\t}\r\n\r\n\t\t\tif ([408, 429, 500, 502, 503, 504].includes(res.status) && attempt < retries) {\r\n\t\t\t\tawait sleep(attempt * 1000);\r\n\t\t\t\tcontinue;\r\n\t\t\t}\r\n\r\n\t\t\treturn res;\r\n\t\t} catch (err) {\r\n\t\t\tlastError = err;\r\n\r\n\t\t\tif (attempt < retries) {\r\n\t\t\t\tawait sleep(attempt * 1000);\r\n\t\t\t\tcontinue;\r\n\t\t\t}\r\n\t\t}\r\n\t}\r\n\r\n\tthrow lastError;\r\n}\r\n\r\nfunction getSendgridSuppressionMode(): SendgridSuppressionMode {\r\n\tconst groupId = process.env.SENDGRID_NEWSLETTER_SUPPRESSION_GROUP_ID?.trim();\r\n\tconst useGlobal = process.env.SENDGRID_USE_GLOBAL_SUPPRESSION?.trim().toLowerCase() === 'true';\r\n\r\n\tif (groupId) {\r\n\t\treturn { type: 'group', groupId };\r\n\t}\r\n\r\n\tif (useGlobal) {\r\n\t\treturn { type: 'global' };\r\n\t}\r\n\r\n\treturn { type: 'none' };\r\n}\r\n\r\nfunction getRequiredSendgridApiKey() {\r\n\tconst apiKey = process.env.SENDGRID_API_KEY;\r\n\r\n\tif (!apiKey) {\r\n\t\tthrow new Error('Missing SENDGRID_API_KEY');\r\n\t}\r\n\r\n\treturn apiKey;\r\n}\r\n\r\nfunction getRequiredUnsubscribeUrlFieldId() {\r\n\tconst unsubscribeUrlFieldId = process.env.SENDGRID_UNSUBSCRIBE_URL_FIELD_ID;\r\n\r\n\tif (!unsubscribeUrlFieldId) {\r\n\t\tthrow new Error('Missing SENDGRID_UNSUBSCRIBE_URL_FIELD_ID');\r\n\t}\r\n\r\n\treturn unsubscribeUrlFieldId;\r\n}\r\n\r\nfunction getOptionalSendgridListId() {\r\n\treturn process.env.SENDGRID_NEWSLETTER_LIST_ID?.trim() || null;\r\n}\r\n\r\nasync function lookupSendgridContactIdByEmail(email: string): Promise<string | null> {\r\n\tconst apiKey = getRequiredSendgridApiKey();\r\n\tconst normalizedEmail = email.trim().toLowerCase();\r\n\r\n\tconst res = await fetchWithRetry(\r\n\t\t'https://api.sendgrid.com/v3/marketing/contacts/search/emails',\r\n\t\t{\r\n\t\t\tmethod: 'POST',\r\n\t\t\theaders: {\r\n\t\t\t\tAuthorization: `Bearer ${apiKey}`,\r\n\t\t\t\t'Content-Type': 'application/json',\r\n\t\t\t},\r\n\t\t\tbody: JSON.stringify({\r\n\t\t\t\temails: [normalizedEmail],\r\n\t\t\t}),\r\n\t\t},\r\n\t\t{ retries: 3, timeoutMs: 10000 }\r\n\t);\r\n\r\n\tif (res.status === 404) {\r\n\t\treturn null;\r\n\t}\r\n\r\n\tif (!res.ok) {\r\n\t\tconst text = await res.text();\r\n\t\tthrow new Error(`SendGrid contact lookup failed: ${res.status} ${res.statusText} ${text}`);\r\n\t}\r\n\r\n\tconst json = (await res.json()) as {\r\n\t\tresult?: Record<string, { contact?: { id?: string } }>;\r\n\t};\r\n\r\n\treturn json?.result?.[normalizedEmail]?.contact?.id ?? null;\r\n}\r\n\r\nasync function removeSendgridContactFromListById(contactId: string) {\r\n\tconst apiKey = getRequiredSendgridApiKey();\r\n\tconst listId = getOptionalSendgridListId();\r\n\r\n\tif (!listId) {\r\n\t\treturn { removed: false, reason: 'missing_list_id' as const };\r\n\t}\r\n\r\n\tconst removeUrl =\r\n\t\t`https://api.sendgrid.com/v3/marketing/lists/${encodeURIComponent(listId)}/contacts` +\r\n\t\t`?contact_ids=${encodeURIComponent(contactId)}`;\r\n\r\n\t// strapi.log.info('[SendGrid remove from list] url:', removeUrl);\r\n\r\n\tconst res = await fetchWithRetry(\r\n\t\tremoveUrl,\r\n\t\t{\r\n\t\t\tmethod: 'DELETE',\r\n\t\t\theaders: {\r\n\t\t\t\tAuthorization: `Bearer ${apiKey}`,\r\n\t\t\t},\r\n\t\t},\r\n\t\t{ retries: 3, timeoutMs: 10000 }\r\n\t);\r\n\r\n\t// strapi.log.info('[SendGrid remove from list] status:', res.status);\r\n\r\n\tif (!res.ok) {\r\n\t\tconst text = await res.text();\r\n\t\tthrow new Error(`SendGrid remove from list failed: ${res.status} ${res.statusText} ${text}`);\r\n\t}\r\n\r\n\treturn { removed: true, listId, contactId };\r\n}\r\n\r\nasync function addEmailToGlobalSuppression(email: string) {\r\n\tconst apiKey = getRequiredSendgridApiKey();\r\n\tconst normalizedEmail = email.trim().toLowerCase();\r\n\r\n\tconst res = await fetchWithRetry(\r\n\t\t'https://api.sendgrid.com/v3/asm/suppressions/global',\r\n\t\t{\r\n\t\t\tmethod: 'POST',\r\n\t\t\theaders: {\r\n\t\t\t\tAuthorization: `Bearer ${apiKey}`,\r\n\t\t\t\t'Content-Type': 'application/json',\r\n\t\t\t},\r\n\t\t\tbody: JSON.stringify({\r\n\t\t\t\trecipient_emails: [normalizedEmail],\r\n\t\t\t}),\r\n\t\t},\r\n\t\t{ retries: 3, timeoutMs: 10000 }\r\n\t);\r\n\r\n\tif (!res.ok) {\r\n\t\tconst text = await res.text();\r\n\t\tthrow new Error(`SendGrid add global suppression failed: ${res.status} ${res.statusText} ${text}`);\r\n\t}\r\n\r\n\treturn { suppressed: true, type: 'global' as const, email: normalizedEmail };\r\n}\r\n\r\nasync function removeEmailFromGlobalSuppression(email: string) {\r\n\tconst apiKey = getRequiredSendgridApiKey();\r\n\tconst normalizedEmail = email.trim().toLowerCase();\r\n\r\n\tconst res = await fetchWithRetry(\r\n\t\t`https://api.sendgrid.com/v3/asm/suppressions/global/${encodeURIComponent(normalizedEmail)}`,\r\n\t\t{\r\n\t\t\tmethod: 'DELETE',\r\n\t\t\theaders: {\r\n\t\t\t\tAuthorization: `Bearer ${apiKey}`,\r\n\t\t\t},\r\n\t\t},\r\n\t\t{ retries: 3, timeoutMs: 10000 }\r\n\t);\r\n\r\n\tif (res.status === 404) {\r\n\t\treturn {\r\n\t\t\tunsuppressed: false,\r\n\t\t\treason: 'not_found' as const,\r\n\t\t\ttype: 'global' as const,\r\n\t\t\temail: normalizedEmail,\r\n\t\t};\r\n\t}\r\n\r\n\tif (!res.ok) {\r\n\t\tconst text = await res.text();\r\n\t\tthrow new Error(`SendGrid remove global suppression failed: ${res.status} ${res.statusText} ${text}`);\r\n\t}\r\n\r\n\treturn { unsuppressed: true, type: 'global' as const, email: normalizedEmail };\r\n}\r\n\r\nasync function addEmailToSuppressionGroup(email: string, groupId: string) {\r\n\tconst apiKey = getRequiredSendgridApiKey();\r\n\tconst normalizedEmail = email.trim().toLowerCase();\r\n\r\n\tconst res = await fetchWithRetry(\r\n\t\t`https://api.sendgrid.com/v3/asm/groups/${encodeURIComponent(groupId)}/suppressions`,\r\n\t\t{\r\n\t\t\tmethod: 'POST',\r\n\t\t\theaders: {\r\n\t\t\t\tAuthorization: `Bearer ${apiKey}`,\r\n\t\t\t\t'Content-Type': 'application/json',\r\n\t\t\t},\r\n\t\t\tbody: JSON.stringify({\r\n\t\t\t\trecipient_emails: [normalizedEmail],\r\n\t\t\t}),\r\n\t\t},\r\n\t\t{ retries: 3, timeoutMs: 10000 }\r\n\t);\r\n\r\n\tif (!res.ok) {\r\n\t\tconst text = await res.text();\r\n\t\tthrow new Error(`SendGrid add group suppression failed: ${res.status} ${res.statusText} ${text}`);\r\n\t}\r\n\r\n\treturn { suppressed: true, type: 'group' as const, groupId, email: normalizedEmail };\r\n}\r\n\r\nasync function removeEmailFromSuppressionGroup(email: string, groupId: string) {\r\n\tconst apiKey = getRequiredSendgridApiKey();\r\n\tconst normalizedEmail = email.trim().toLowerCase();\r\n\r\n\tconst res = await fetchWithRetry(\r\n\t\t`https://api.sendgrid.com/v3/asm/groups/${encodeURIComponent(groupId)}/suppressions/${encodeURIComponent(normalizedEmail)}`,\r\n\t\t{\r\n\t\t\tmethod: 'DELETE',\r\n\t\t\theaders: {\r\n\t\t\t\tAuthorization: `Bearer ${apiKey}`,\r\n\t\t\t},\r\n\t\t},\r\n\t\t{ retries: 3, timeoutMs: 10000 }\r\n\t);\r\n\r\n\tif (res.status === 404) {\r\n\t\treturn {\r\n\t\t\tunsuppressed: false,\r\n\t\t\treason: 'not_found' as const,\r\n\t\t\ttype: 'group' as const,\r\n\t\t\tgroupId,\r\n\t\t\temail: normalizedEmail,\r\n\t\t};\r\n\t}\r\n\r\n\tif (!res.ok) {\r\n\t\tconst text = await res.text();\r\n\t\tthrow new Error(`SendGrid remove group suppression failed: ${res.status} ${res.statusText} ${text}`);\r\n\t}\r\n\r\n\treturn { unsuppressed: true, type: 'group' as const, groupId, email: normalizedEmail };\r\n}\r\n\r\nasync function resubscribeSendgridContactByEmail(email: string) {\r\n\tconst normalizedEmail = email.trim().toLowerCase();\r\n\tconst suppressionMode = getSendgridSuppressionMode();\r\n\r\n\tif (suppressionMode.type === 'none') {\r\n\t\treturn {\r\n\t\t\tok: true,\r\n\t\t\temail: normalizedEmail,\r\n\t\t\tsuppression: { removed: false, reason: 'disabled' as const },\r\n\t\t};\r\n\t}\r\n\r\n\tif (suppressionMode.type === 'global') {\r\n\t\tconst result = await removeEmailFromGlobalSuppression(normalizedEmail);\r\n\r\n\t\treturn {\r\n\t\t\tok: true,\r\n\t\t\temail: normalizedEmail,\r\n\t\t\tsuppression: result,\r\n\t\t};\r\n\t}\r\n\r\n\tconst result = await removeEmailFromSuppressionGroup(normalizedEmail, suppressionMode.groupId);\r\n\r\n\treturn {\r\n\t\tok: true,\r\n\t\temail: normalizedEmail,\r\n\t\tsuppression: result,\r\n\t};\r\n}\r\n\r\nasync function unsubscribeSendgridContactByEmail(email: string) {\r\n\tconst normalizedEmail = email.trim().toLowerCase();\r\n\tconst suppressionMode = getSendgridSuppressionMode();\r\n\r\n\tconst contactId = await lookupSendgridContactIdByEmail(normalizedEmail);\r\n\r\n\t// strapi.log.info('[SendGrid unsubscribe] email:', normalizedEmail);\r\n\t// strapi.log.info('[SendGrid unsubscribe] contactId:', contactId);\r\n\t// strapi.log.info('[SendGrid unsubscribe] suppressionMode:', suppressionMode);\r\n\t// strapi.log.info(`[SendGrid lookup] email=${normalizedEmail} contactId=${contactId ?? 'null'}`);\r\n\r\n\tlet listResult:\r\n\t\t| { removed: boolean; listId: string; contactId: string }\r\n\t\t| { removed: boolean; reason: 'not_found' | 'missing_list_id' };\r\n\r\n\tif (!contactId) {\r\n\t\tlistResult = { removed: false, reason: 'not_found' };\r\n\t} else {\r\n\t\tlistResult = await removeSendgridContactFromListById(contactId);\r\n\t}\r\n\r\n\t// strapi.log.info('[SendGrid unsubscribe] listResult:', listResult);\r\n\r\n\tlet suppressionResult:\r\n\t\t| { applied: false; reason: 'disabled' }\r\n\t\t| { applied: true; type: 'global' | 'group'; email: string; groupId?: string };\r\n\r\n\tif (suppressionMode.type === 'none') {\r\n\t\tsuppressionResult = { applied: false, reason: 'disabled' };\r\n\t} else if (suppressionMode.type === 'global') {\r\n\t\tawait addEmailToGlobalSuppression(normalizedEmail);\r\n\t\tsuppressionResult = {\r\n\t\t\tapplied: true,\r\n\t\t\ttype: 'global',\r\n\t\t\temail: normalizedEmail,\r\n\t\t};\r\n\t} else {\r\n\t\tawait addEmailToSuppressionGroup(normalizedEmail, suppressionMode.groupId);\r\n\t\tsuppressionResult = {\r\n\t\t\tapplied: true,\r\n\t\t\ttype: 'group',\r\n\t\t\tgroupId: suppressionMode.groupId,\r\n\t\t\temail: normalizedEmail,\r\n\t\t};\r\n\t}\r\n\r\n\t// strapi.log.info('[SendGrid unsubscribe] suppressionResult:', suppressionResult);\r\n\r\n\treturn {\r\n\t\tok: true,\r\n\t\temail: normalizedEmail,\r\n\t\tlist: listResult,\r\n\t\tsuppression: suppressionResult,\r\n\t};\r\n}\r\n\r\nasync function upsertSendgridContact(data: SendgridContactInput) {\r\n\tconst apiKey = getRequiredSendgridApiKey();\r\n\tconst listId = getOptionalSendgridListId();\r\n\tconst unsubscribeUrlFieldId = getRequiredUnsubscribeUrlFieldId();\r\n\r\n\tconst payload: Record<string, unknown> = {\r\n\t\tcontacts: [\r\n\t\t\t{\r\n\t\t\t\temail: data.email.trim().toLowerCase(),\r\n\t\t\t\tfirst_name: data.first_name.trim(),\r\n\t\t\t\tlast_name: data.last_name.trim(),\r\n\t\t\t\tcustom_fields: {\r\n\t\t\t\t\t[unsubscribeUrlFieldId]: data.unsubscribe_url,\r\n\t\t\t\t},\r\n\t\t\t},\r\n\t\t],\r\n\t};\r\n\r\n\tif (listId) {\r\n\t\tpayload.list_ids = [listId];\r\n\t}\r\n\r\n\tlet res: Response;\r\n\r\n\ttry {\r\n\t\tres = await fetchWithRetry(\r\n\t\t\t'https://api.sendgrid.com/v3/marketing/contacts',\r\n\t\t\t{\r\n\t\t\t\tmethod: 'PUT',\r\n\t\t\t\theaders: {\r\n\t\t\t\t\tAuthorization: `Bearer ${apiKey}`,\r\n\t\t\t\t\t'Content-Type': 'application/json',\r\n\t\t\t\t},\r\n\t\t\t\tbody: JSON.stringify(payload),\r\n\t\t\t},\r\n\t\t\t{ retries: 3, timeoutMs: 10000 }\r\n\t\t);\r\n\t} catch (err) {\r\n\t\tthrow new Error(`SendGrid contact upsert fetch failed: ${getErrorDetails(err)}`);\r\n\t}\r\n\r\n\tif (!res.ok) {\r\n\t\tconst text = await res.text();\r\n\t\tthrow new Error(`SendGrid contact upsert failed: ${res.status} ${res.statusText} ${text}`);\r\n\t}\r\n\r\n\treturn res.json();\r\n}\r\n\r\nasync function upsertSendgridContactBatch(contacts: SendgridContactInput[]) {\r\n\tconst apiKey = getRequiredSendgridApiKey();\r\n\tconst listId = getOptionalSendgridListId();\r\n\tconst unsubscribeUrlFieldId = getRequiredUnsubscribeUrlFieldId();\r\n\r\n\tconst payload: Record<string, unknown> = {\r\n\t\tcontacts: contacts.map((c) => ({\r\n\t\t\temail: c.email.trim().toLowerCase(),\r\n\t\t\tfirst_name: c.first_name.trim(),\r\n\t\t\tlast_name: c.last_name.trim(),\r\n\t\t\tcustom_fields: {\r\n\t\t\t\t[unsubscribeUrlFieldId]: c.unsubscribe_url,\r\n\t\t\t},\r\n\t\t})),\r\n\t};\r\n\r\n\tif (listId) {\r\n\t\tpayload.list_ids = [listId];\r\n\t}\r\n\r\n\tconst res = await fetchWithRetry(\r\n\t\t'https://api.sendgrid.com/v3/marketing/contacts',\r\n\t\t{\r\n\t\t\tmethod: 'PUT',\r\n\t\t\theaders: {\r\n\t\t\t\tAuthorization: `Bearer ${apiKey}`,\r\n\t\t\t\t'Content-Type': 'application/json',\r\n\t\t\t},\r\n\t\t\tbody: JSON.stringify(payload),\r\n\t\t},\r\n\t\t{ retries: 3, timeoutMs: 10000 }\r\n\t);\r\n\r\n\tif (!res.ok) {\r\n\t\tconst text = await res.text();\r\n\t\tthrow new Error(`SendGrid batch upsert failed: ${res.status} ${res.statusText} ${text}`);\r\n\t}\r\n\r\n\treturn res.json();\r\n}\r\n\r\nasync function ensureUnsubscribeToken(strapi: any, row: any): Promise<string> {\r\n\tif (row.unsubscribe_token) {\r\n\t\treturn row.unsubscribe_token;\r\n\t}\r\n\r\n\tconst newToken = crypto.randomUUID();\r\n\r\n\tawait strapi.documents('api::subscription.subscription').update({\r\n\t\tdocumentId: row.documentId,\r\n\t\tdata: {\r\n\t\t\tunsubscribe_token: newToken,\r\n\t\t},\r\n\t});\r\n\r\n\t// strapi.log.info(`Generated missing unsubscribe_token for subscription ${row.documentId}`);\r\n\r\n\treturn newToken;\r\n}\r\n\r\nasync function buildSendgridContactFromSubscription(strapi: any, row: any): Promise<SendgridContactInput> {\r\n\tconst unsubscribeToken = await ensureUnsubscribeToken(strapi, row);\r\n\r\n\treturn {\r\n\t\temail: row.email.trim().toLowerCase(),\r\n\t\tfirst_name: row.first_name?.trim() ?? '',\r\n\t\tlast_name: row.last_name?.trim() ?? '',\r\n\t\tunsubscribe_url: buildUnsubscribeUrl(unsubscribeToken),\r\n\t};\r\n}\r\n\r\nasync function syncSubscriptionRowToSendgrid(strapi: any, row: any) {\r\n\tconst email = row.email.trim().toLowerCase();\r\n\r\n\tif (row.subscription_status === 'subscribed') {\r\n\t\tconst contact = await buildSendgridContactFromSubscription(strapi, row);\r\n\t\tawait resubscribeSendgridContactByEmail(contact.email);\r\n\t\tawait upsertSendgridContact(contact);\r\n\r\n\t\treturn {\r\n\t\t\tok: true,\r\n\t\t\tcode: 'resynced-subscribed',\r\n\t\t\temail: contact.email,\r\n\t\t\tsubscription_status: row.subscription_status,\r\n\t\t};\r\n\t}\r\n\r\n\tif (row.subscription_status === 'unsubscribed') {\r\n\t\tawait unsubscribeSendgridContactByEmail(email);\r\n\r\n\t\treturn {\r\n\t\t\tok: true,\r\n\t\t\tcode: 'resynced-unsubscribed',\r\n\t\t\temail,\r\n\t\t\tsubscription_status: row.subscription_status,\r\n\t\t};\r\n\t}\r\n\r\n\treturn {\r\n\t\tok: false,\r\n\t\tcode: 'unsupported-status',\r\n\t\temail,\r\n\t\tsubscription_status: row.subscription_status,\r\n\t};\r\n}\r\n\r\nexport default factories.createCoreService('api::subscription.subscription', ({ strapi }) => ({\r\n\tasync create(params) {\r\n\t\tconst raw = params?.data as SubscriptionInput;\r\n\t\tconst data = normalizeInput(raw);\r\n\r\n\t\tconst existing = await strapi.documents('api::subscription.subscription').findMany({\r\n\t\t\tfilters: { email: data.email },\r\n\t\t\tlimit: 1,\r\n\t\t});\r\n\r\n\t\tconst found = existing[0];\r\n\t\tconst title = buildTitle(data);\r\n\r\n\t\tif (!found) {\r\n\t\t\tconst unsubscribeToken = crypto.randomUUID();\r\n\t\t\tconst unsubscribeUrl = buildUnsubscribeUrl(unsubscribeToken);\r\n\r\n\t\t\tconst result = await super.create({\r\n\t\t\t\t...params,\r\n\t\t\t\tdata: {\r\n\t\t\t\t\t...params.data,\r\n\t\t\t\t\t...data,\r\n\t\t\t\t\ttitle,\r\n\t\t\t\t\tsubscription_status: 'subscribed',\r\n\t\t\t\t\tunsubscribe_token: unsubscribeToken,\r\n\t\t\t\t\tresubscribed_at: new Date().toISOString(),\r\n\t\t\t\t},\r\n\t\t\t});\r\n\r\n\t\t\ttry {\r\n\t\t\t\tawait resubscribeSendgridContactByEmail(data.email);\r\n\r\n\t\t\t\tawait upsertSendgridContact({\r\n\t\t\t\t\t...data,\r\n\t\t\t\t\tunsubscribe_url: unsubscribeUrl,\r\n\t\t\t\t});\r\n\t\t\t} catch (err) {\r\n\t\t\t\tif (err instanceof Error) {\r\n\t\t\t\t\tstrapi.log.error(`SendGrid sync error on create: ${getErrorDetails(err)}`);\r\n\t\t\t\t} else {\r\n\t\t\t\t\tstrapi.log.error(`SendGrid sync error on create: ${JSON.stringify(err)}`);\r\n\t\t\t\t}\r\n\t\t\t}\r\n\r\n\t\t\treturn result;\r\n\t\t}\r\n\r\n\t\tif (found.subscription_status === 'subscribed') {\r\n\t\t\treturn {\r\n\t\t\t\t...found,\r\n\t\t\t\tsubscriptionState: 'already-subscribed',\r\n\t\t\t};\r\n\t\t}\r\n\r\n\t\tconst newToken = crypto.randomUUID();\r\n\t\tconst unsubscribeUrl = buildUnsubscribeUrl(newToken);\r\n\r\n\t\tconst updated = await strapi.documents('api::subscription.subscription').update({\r\n\t\t\tdocumentId: found.documentId,\r\n\t\t\tdata: {\r\n\t\t\t\ttitle,\r\n\t\t\t\tfirst_name: data.first_name,\r\n\t\t\t\tlast_name: data.last_name,\r\n\t\t\t\tsubscription_status: 'subscribed',\r\n\t\t\t\tunsubscribed_at: null,\r\n\t\t\t\tresubscribed_at: new Date().toISOString(),\r\n\t\t\t\tunsubscribe_token: newToken,\r\n\t\t\t},\r\n\t\t});\r\n\r\n\t\ttry {\r\n\t\t\tawait resubscribeSendgridContactByEmail(data.email);\r\n\r\n\t\t\tawait upsertSendgridContact({\r\n\t\t\t\t...data,\r\n\t\t\t\tunsubscribe_url: unsubscribeUrl,\r\n\t\t\t});\r\n\t\t} catch (err) {\r\n\t\t\tif (err instanceof Error) {\r\n\t\t\t\tstrapi.log.error(`SendGrid sync error on resubscribe: ${getErrorDetails(err)}`);\r\n\t\t\t} else {\r\n\t\t\t\tstrapi.log.error(`SendGrid sync error on resubscribe: ${JSON.stringify(err)}`);\r\n\t\t\t}\r\n\t\t}\r\n\r\n\t\treturn {\r\n\t\t\t...updated,\r\n\t\t\tsubscriptionState: 'resubscribed',\r\n\t\t};\r\n\t},\r\n\r\n\tasync unsubscribeByToken(token: string) {\r\n\t\tconst entries = await strapi.documents('api::subscription.subscription').findMany({\r\n\t\t\tfilters: { unsubscribe_token: token },\r\n\t\t\tlimit: 1,\r\n\t\t});\r\n\r\n\t\tconst found = entries[0];\r\n\r\n\t\tif (!found) {\r\n\t\t\treturn { ok: false, code: 'invalid-token' };\r\n\t\t}\r\n\r\n\t\tif (found.subscription_status === 'unsubscribed') {\r\n\t\t\treturn { ok: true, code: 'already-unsubscribed' };\r\n\t\t}\r\n\r\n\t\tconst updated = await strapi.documents('api::subscription.subscription').update({\r\n\t\t\tdocumentId: found.documentId,\r\n\t\t\tdata: {\r\n\t\t\t\tsubscription_status: 'unsubscribed',\r\n\t\t\t\tunsubscribed_at: new Date().toISOString(),\r\n\t\t\t},\r\n\t\t});\r\n\r\n\t\ttry {\r\n\t\t\t// strapi.log.info(`[unsubscribeByToken] calling SendGrid unsubscribe for: ${found.email}`);\r\n\t\t\tconst sgResult = await unsubscribeSendgridContactByEmail(found.email);\r\n\t\t\t// strapi.log.info(`[unsubscribeByToken] SendGrid result: ${JSON.stringify(sgResult)}`);\r\n\t\t} catch (err) {\r\n\t\t\tif (err instanceof Error) {\r\n\t\t\t\tstrapi.log.error(`SendGrid sync error on unsubscribe: ${getErrorDetails(err)}`);\r\n\t\t\t} else {\r\n\t\t\t\tstrapi.log.error(`SendGrid sync error on unsubscribe: ${JSON.stringify(err)}`);\r\n\t\t\t}\r\n\t\t}\r\n\r\n\t\treturn { ok: true, code: 'unsubscribed', data: updated };\r\n\t},\r\n\r\n\tasync resyncAllSubscribedToSendgrid() {\r\n\t\tconst pluginSettings = await getSendgridToolsSettings(strapi);\r\n\t\tconst syncUnsubscribed = pluginSettings.syncUnsubscribed;\r\n\r\n\t\tconst pageSize = 500;\r\n\t\tlet start = 0;\r\n\t\tlet totalSynced = 0;\r\n\r\n\t\tconst allowedStatuses: SubscriptionStatus[] = syncUnsubscribed\r\n\t\t\t? ['subscribed', 'unsubscribed']\r\n\t\t\t: ['subscribed'];\r\n\r\n\t\twhile (true) {\r\n\t\t\tconst rows = await strapi.documents('api::subscription.subscription').findMany({\r\n\t\t\t\tfilters: {\r\n\t\t\t\t\tsubscription_status: {\r\n\t\t\t\t\t\t$in: allowedStatuses,\r\n\t\t\t\t\t},\r\n\t\t\t\t},\r\n\t\t\t\tsort: { email: 'asc' },\r\n\t\t\t\tstart,\r\n\t\t\t\tlimit: pageSize,\r\n\t\t\t});\r\n\r\n\t\t\tif (!rows.length) {\r\n\t\t\t\tbreak;\r\n\t\t\t}\r\n\r\n\t\t\ttry {\r\n\t\t\t\tconst subscribedRows = rows.filter((row: any) => row.subscription_status === 'subscribed');\r\n\t\t\t\tconst unsubscribedRows = rows.filter((row: any) => row.subscription_status === 'unsubscribed');\r\n\r\n\t\t\t\tif (subscribedRows.length > 0) {\r\n\t\t\t\t\tconst contacts = await Promise.all(\r\n\t\t\t\t\t\tsubscribedRows.map((row: any) => buildSendgridContactFromSubscription(strapi, row))\r\n\t\t\t\t\t);\r\n\r\n\t\t\t\t\tfor (const contact of contacts) {\r\n\t\t\t\t\t\tawait resubscribeSendgridContactByEmail(contact.email);\r\n\t\t\t\t\t}\r\n\r\n\t\t\t\t\tawait upsertSendgridContactBatch(contacts);\r\n\t\t\t\t\ttotalSynced += contacts.length;\r\n\t\t\t\t}\r\n\r\n\t\t\t\tif (syncUnsubscribed && unsubscribedRows.length > 0) {\r\n\t\t\t\t\tfor (const row of unsubscribedRows) {\r\n\t\t\t\t\t\tawait unsubscribeSendgridContactByEmail(row.email);\r\n\t\t\t\t\t\ttotalSynced += 1;\r\n\t\t\t\t\t}\r\n\t\t\t\t}\r\n\t\t\t} catch (err) {\r\n\t\t\t\tif (err instanceof Error) {\r\n\t\t\t\t\tstrapi.log.error(`SendGrid batch sync error at start ${start}: ${getErrorDetails(err)}`);\r\n\t\t\t\t} else {\r\n\t\t\t\t\tstrapi.log.error(`SendGrid batch sync error at start ${start}: ${JSON.stringify(err)}`);\r\n\t\t\t\t}\r\n\t\t\t\tthrow err;\r\n\t\t\t}\r\n\r\n\t\t\tif (rows.length < pageSize) {\r\n\t\t\t\tbreak;\r\n\t\t\t}\r\n\r\n\t\t\tstart += pageSize;\r\n\t\t}\r\n\r\n\t\treturn { ok: true, totalSynced };\r\n\t},\r\n\r\n\tasync resyncOneSubscriptionToSendgrid(documentId: string) {\r\n\t\tconst row = await strapi.documents('api::subscription.subscription').findOne({\r\n\t\t\tdocumentId,\r\n\t\t});\r\n\r\n\t\tif (!row) {\r\n\t\t\treturn { ok: false, code: 'not-found' };\r\n\t\t}\r\n\r\n\t\tconst pluginSettings = await getSendgridToolsSettings(strapi);\r\n\r\n\t\tif (row.subscription_status === 'subscribed') {\r\n\t\t\tconst contact = await buildSendgridContactFromSubscription(strapi, row);\r\n\r\n\t\t\tawait resubscribeSendgridContactByEmail(contact.email);\r\n\t\t\tawait upsertSendgridContact(contact);\r\n\r\n\t\t\treturn {\r\n\t\t\t\tok: true,\r\n\t\t\t\tcode: 'resynced',\r\n\t\t\t\tdocumentId,\r\n\t\t\t\temail: contact.email,\r\n\t\t\t\tsubscription_status: row.subscription_status,\r\n\t\t\t};\r\n\t\t}\r\n\r\n\t\tif (row.subscription_status === 'unsubscribed') {\r\n\t\t\tif (!pluginSettings.syncUnsubscribed) {\r\n\t\t\t\treturn {\r\n\t\t\t\t\tok: false,\r\n\t\t\t\t\tcode: 'not-subscribed',\r\n\t\t\t\t\tmessage: 'Csak subscribed állapotú rekord szinkronizálható.',\r\n\t\t\t\t};\r\n\t\t\t}\r\n\r\n\t\t\tawait unsubscribeSendgridContactByEmail(row.email);\r\n\r\n\t\t\treturn {\r\n\t\t\t\tok: true,\r\n\t\t\t\tcode: 'resynced',\r\n\t\t\t\tdocumentId,\r\n\t\t\t\temail: row.email.trim().toLowerCase(),\r\n\t\t\t\tsubscription_status: row.subscription_status,\r\n\t\t\t};\r\n\t\t}\r\n\r\n\t\treturn {\r\n\t\t\tok: false,\r\n\t\t\tcode: 'not-supported-status',\r\n\t\t\tmessage: `Nem támogatott subscription_status: ${row.subscription_status}`,\r\n\t\t};\r\n\t},\r\n\r\n\r\n}));\r\n\r\n\r\n\r\n","readmeFilename":"README.md","_rev":"1-ede080af2254784ff6d68c3a983047a5"}