@cslegany/strapi5-sendgrid-tools
v0.0.1
Published
SendGrid sync integration with Strapi5
Downloads
54
Readme
sendgrid-tools
Use it with the following entity in your strapi project: subscription.
schema.json: { "kind": "collectionType", "collectionName": "subscriptions", "info": { "singularName": "subscription", "pluralName": "subscriptions", "displayName": "Subscription", "description": "" }, "options": { "draftAndPublish": false }, "pluginOptions": {}, "attributes": { "title": { "type": "string" }, "email": { "type": "email", "required": true, "unique": true }, "first_name": { "type": "string", "required": true }, "last_name": { "type": "string", "required": true }, "subscription_status": { "type": "enumeration", "enum": [ "subscribed", "unsubscribed" ], "default": "subscribed", "required": true }, "unsubscribed_at": { "type": "datetime" }, "resubscribed_at": { "type": "datetime" }, "unsubscribe_token": { "type": "string", "unique": true } } }
controllers/subscription.ts: import { factories } from '@strapi/strapi';
export default factories.createCoreController('api::subscription.subscription', ({ strapi }) => ({ async unsubscribe(ctx) { const token = ctx.request.body?.token;
if (!token) {
return ctx.badRequest('Missing token');
}
const result = await strapi
.service('api::subscription.subscription')
.unsubscribeByToken(token);
if (!result.ok && result.code === 'invalid-token') {
return ctx.notFound('Invalid token');
}
return ctx.send(result);
},
async resyncSendgrid(ctx) {
const internalToken = ctx.request.header.authorization?.replace('Bearer ', '');
const expectedToken = process.env.INTERNAL_SYNC_TOKEN;
if (!expectedToken || internalToken !== expectedToken) {
return ctx.unauthorized('Invalid token');
}
const result = await strapi
.service('api::subscription.subscription')
.resyncAllSubscribedToSendgrid();
return ctx.send(result);
},
async getSendgridCustomFields(ctx) {
const internalToken = ctx.request.header.authorization?.replace('Bearer ', '');
const expectedToken = process.env.INTERNAL_SYNC_TOKEN;
const isDev = process.env.NODE_ENV === 'development';
const apiKey = process.env.SENDGRID_API_KEY;
if (!isDev && (!expectedToken || internalToken !== expectedToken)) {
return ctx.unauthorized('Invalid token');
}
if (!apiKey) {
return ctx.internalServerError('Missing SENDGRID_API_KEY');
}
const res = await fetch('https://api.sendgrid.com/v3/marketing/field_definitions', {
method: 'GET',
headers: {
Authorization: `Bearer ${apiKey}`,
'Content-Type': 'application/json',
},
});
if (!res.ok) {
const text = await res.text();
strapi.log.error(`SendGrid field definitions failed: ${res.status} ${text}`);
return ctx.internalServerError(`SendGrid field definitions failed: ${res.status}`);
}
const json = await res.json();
return ctx.send(json);
},}));
routes/subscription.ts: export default { routes: [ { method: 'POST', path: '/subscriptions/resync-sendgrid', handler: 'subscription.resyncSendgrid', config: { auth: false, }, }, { method: 'GET', path: '/subscriptions/sendgrid-custom-fields', handler: 'subscription.getSendgridCustomFields', config: { auth: false, }, }, { method: 'POST', path: '/subscriptions/unsubscribe', handler: 'subscription.unsubscribe', config: { auth: false, }, }, ], };
services/subscription.ts: import { factories } from '@strapi/strapi'; import crypto from 'crypto';
type SendgridToolsSettings = { enabled: boolean; syncUnsubscribed: boolean; };
async function getSendgridToolsSettings(strapi: any): Promise { const settings = await strapi .plugin('sendgrid-tools') .service('settings') .getSettings();
return {
enabled: settings?.enabled ?? true,
syncUnsubscribed: settings?.syncUnsubscribed ?? false,
};}
type SubscriptionInput = { email: string; first_name: string; last_name: string; };
type SendgridContactInput = { email: string; first_name: string; last_name: string; unsubscribe_url: string; };
type SendgridSuppressionMode = | { type: 'none' } | { type: 'global' } | { type: 'group'; groupId: string };
type SubscriptionStatus = 'subscribed' | 'unsubscribed';
function normalizeInput(data: SubscriptionInput): SubscriptionInput { return { email: data.email.trim().toLowerCase(), first_name: data.first_name.trim(), last_name: data.last_name.trim(), }; }
function buildTitle(data: SubscriptionInput) {
return ${data.last_name.trim()} ${data.first_name.trim()} <${data.email.trim().toLowerCase()}>;
}
function buildUnsubscribeUrl(token: string) { const frontendUrl = process.env.FRONTEND_URL;
if (!frontendUrl) {
throw new Error('Missing FRONTEND_URL');
}
return `${frontendUrl.replace(/\/$/, '')}/hirlevel/leiratkozas?token=${encodeURIComponent(token)}`;}
function getErrorDetails(err: unknown) { if (err instanceof Error) { const cause = (err as Error & { cause?: unknown }).cause;
if (cause instanceof Error) {
return `${err.name}: ${err.message} | cause: ${cause.name}: ${cause.message}`;
}
if (cause) {
try {
return `${err.name}: ${err.message} | cause: ${JSON.stringify(cause)}`;
} catch {
return `${err.name}: ${err.message} | cause: ${String(cause)}`;
}
}
return `${err.name}: ${err.message}`;
}
try {
return JSON.stringify(err);
} catch {
return String(err);
}}
function sleep(ms: number) { return new Promise((resolve) => setTimeout(resolve, ms)); }
async function fetchWithTimeout(url: string, options: RequestInit = {}, timeoutMs = 10000) { const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), timeoutMs);
try {
return await fetch(url, {
...options,
signal: controller.signal,
});
} finally {
clearTimeout(timeout);
}}
async function fetchWithRetry( url: string, options: RequestInit, { retries = 3, timeoutMs = 10000, }: { retries?: number; timeoutMs?: number } = {} ) { let lastError: unknown;
for (let attempt = 1; attempt <= retries; attempt++) {
try {
const res = await fetchWithTimeout(url, options, timeoutMs);
if (res.ok) {
return res;
}
if ([408, 429, 500, 502, 503, 504].includes(res.status) && attempt < retries) {
await sleep(attempt * 1000);
continue;
}
return res;
} catch (err) {
lastError = err;
if (attempt < retries) {
await sleep(attempt * 1000);
continue;
}
}
}
throw lastError;}
function getSendgridSuppressionMode(): SendgridSuppressionMode { const groupId = process.env.SENDGRID_NEWSLETTER_SUPPRESSION_GROUP_ID?.trim(); const useGlobal = process.env.SENDGRID_USE_GLOBAL_SUPPRESSION?.trim().toLowerCase() === 'true';
if (groupId) {
return { type: 'group', groupId };
}
if (useGlobal) {
return { type: 'global' };
}
return { type: 'none' };}
function getRequiredSendgridApiKey() { const apiKey = process.env.SENDGRID_API_KEY;
if (!apiKey) {
throw new Error('Missing SENDGRID_API_KEY');
}
return apiKey;}
function getRequiredUnsubscribeUrlFieldId() { const unsubscribeUrlFieldId = process.env.SENDGRID_UNSUBSCRIBE_URL_FIELD_ID;
if (!unsubscribeUrlFieldId) {
throw new Error('Missing SENDGRID_UNSUBSCRIBE_URL_FIELD_ID');
}
return unsubscribeUrlFieldId;}
function getOptionalSendgridListId() { return process.env.SENDGRID_NEWSLETTER_LIST_ID?.trim() || null; }
async function lookupSendgridContactIdByEmail(email: string): Promise<string | null> { const apiKey = getRequiredSendgridApiKey(); const normalizedEmail = email.trim().toLowerCase();
const res = await fetchWithRetry(
'https://api.sendgrid.com/v3/marketing/contacts/search/emails',
{
method: 'POST',
headers: {
Authorization: `Bearer ${apiKey}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
emails: [normalizedEmail],
}),
},
{ retries: 3, timeoutMs: 10000 }
);
if (res.status === 404) {
return null;
}
if (!res.ok) {
const text = await res.text();
throw new Error(`SendGrid contact lookup failed: ${res.status} ${res.statusText} ${text}`);
}
const json = (await res.json()) as {
result?: Record<string, { contact?: { id?: string } }>;
};
return json?.result?.[normalizedEmail]?.contact?.id ?? null;}
async function removeSendgridContactFromListById(contactId: string) { const apiKey = getRequiredSendgridApiKey(); const listId = getOptionalSendgridListId();
if (!listId) {
return { removed: false, reason: 'missing_list_id' as const };
}
const removeUrl =
`https://api.sendgrid.com/v3/marketing/lists/${encodeURIComponent(listId)}/contacts` +
`?contact_ids=${encodeURIComponent(contactId)}`;
// strapi.log.info('[SendGrid remove from list] url:', removeUrl);
const res = await fetchWithRetry(
removeUrl,
{
method: 'DELETE',
headers: {
Authorization: `Bearer ${apiKey}`,
},
},
{ retries: 3, timeoutMs: 10000 }
);
// strapi.log.info('[SendGrid remove from list] status:', res.status);
if (!res.ok) {
const text = await res.text();
throw new Error(`SendGrid remove from list failed: ${res.status} ${res.statusText} ${text}`);
}
return { removed: true, listId, contactId };}
async function addEmailToGlobalSuppression(email: string) { const apiKey = getRequiredSendgridApiKey(); const normalizedEmail = email.trim().toLowerCase();
const res = await fetchWithRetry(
'https://api.sendgrid.com/v3/asm/suppressions/global',
{
method: 'POST',
headers: {
Authorization: `Bearer ${apiKey}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
recipient_emails: [normalizedEmail],
}),
},
{ retries: 3, timeoutMs: 10000 }
);
if (!res.ok) {
const text = await res.text();
throw new Error(`SendGrid add global suppression failed: ${res.status} ${res.statusText} ${text}`);
}
return { suppressed: true, type: 'global' as const, email: normalizedEmail };}
async function removeEmailFromGlobalSuppression(email: string) { const apiKey = getRequiredSendgridApiKey(); const normalizedEmail = email.trim().toLowerCase();
const res = await fetchWithRetry(
`https://api.sendgrid.com/v3/asm/suppressions/global/${encodeURIComponent(normalizedEmail)}`,
{
method: 'DELETE',
headers: {
Authorization: `Bearer ${apiKey}`,
},
},
{ retries: 3, timeoutMs: 10000 }
);
if (res.status === 404) {
return {
unsuppressed: false,
reason: 'not_found' as const,
type: 'global' as const,
email: normalizedEmail,
};
}
if (!res.ok) {
const text = await res.text();
throw new Error(`SendGrid remove global suppression failed: ${res.status} ${res.statusText} ${text}`);
}
return { unsuppressed: true, type: 'global' as const, email: normalizedEmail };}
async function addEmailToSuppressionGroup(email: string, groupId: string) { const apiKey = getRequiredSendgridApiKey(); const normalizedEmail = email.trim().toLowerCase();
const res = await fetchWithRetry(
`https://api.sendgrid.com/v3/asm/groups/${encodeURIComponent(groupId)}/suppressions`,
{
method: 'POST',
headers: {
Authorization: `Bearer ${apiKey}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
recipient_emails: [normalizedEmail],
}),
},
{ retries: 3, timeoutMs: 10000 }
);
if (!res.ok) {
const text = await res.text();
throw new Error(`SendGrid add group suppression failed: ${res.status} ${res.statusText} ${text}`);
}
return { suppressed: true, type: 'group' as const, groupId, email: normalizedEmail };}
async function removeEmailFromSuppressionGroup(email: string, groupId: string) { const apiKey = getRequiredSendgridApiKey(); const normalizedEmail = email.trim().toLowerCase();
const res = await fetchWithRetry(
`https://api.sendgrid.com/v3/asm/groups/${encodeURIComponent(groupId)}/suppressions/${encodeURIComponent(normalizedEmail)}`,
{
method: 'DELETE',
headers: {
Authorization: `Bearer ${apiKey}`,
},
},
{ retries: 3, timeoutMs: 10000 }
);
if (res.status === 404) {
return {
unsuppressed: false,
reason: 'not_found' as const,
type: 'group' as const,
groupId,
email: normalizedEmail,
};
}
if (!res.ok) {
const text = await res.text();
throw new Error(`SendGrid remove group suppression failed: ${res.status} ${res.statusText} ${text}`);
}
return { unsuppressed: true, type: 'group' as const, groupId, email: normalizedEmail };}
async function resubscribeSendgridContactByEmail(email: string) { const normalizedEmail = email.trim().toLowerCase(); const suppressionMode = getSendgridSuppressionMode();
if (suppressionMode.type === 'none') {
return {
ok: true,
email: normalizedEmail,
suppression: { removed: false, reason: 'disabled' as const },
};
}
if (suppressionMode.type === 'global') {
const result = await removeEmailFromGlobalSuppression(normalizedEmail);
return {
ok: true,
email: normalizedEmail,
suppression: result,
};
}
const result = await removeEmailFromSuppressionGroup(normalizedEmail, suppressionMode.groupId);
return {
ok: true,
email: normalizedEmail,
suppression: result,
};}
async function unsubscribeSendgridContactByEmail(email: string) { const normalizedEmail = email.trim().toLowerCase(); const suppressionMode = getSendgridSuppressionMode();
const contactId = await lookupSendgridContactIdByEmail(normalizedEmail);
// strapi.log.info('[SendGrid unsubscribe] email:', normalizedEmail);
// strapi.log.info('[SendGrid unsubscribe] contactId:', contactId);
// strapi.log.info('[SendGrid unsubscribe] suppressionMode:', suppressionMode);
// strapi.log.info(`[SendGrid lookup] email=${normalizedEmail} contactId=${contactId ?? 'null'}`);
let listResult:
| { removed: boolean; listId: string; contactId: string }
| { removed: boolean; reason: 'not_found' | 'missing_list_id' };
if (!contactId) {
listResult = { removed: false, reason: 'not_found' };
} else {
listResult = await removeSendgridContactFromListById(contactId);
}
// strapi.log.info('[SendGrid unsubscribe] listResult:', listResult);
let suppressionResult:
| { applied: false; reason: 'disabled' }
| { applied: true; type: 'global' | 'group'; email: string; groupId?: string };
if (suppressionMode.type === 'none') {
suppressionResult = { applied: false, reason: 'disabled' };
} else if (suppressionMode.type === 'global') {
await addEmailToGlobalSuppression(normalizedEmail);
suppressionResult = {
applied: true,
type: 'global',
email: normalizedEmail,
};
} else {
await addEmailToSuppressionGroup(normalizedEmail, suppressionMode.groupId);
suppressionResult = {
applied: true,
type: 'group',
groupId: suppressionMode.groupId,
email: normalizedEmail,
};
}
// strapi.log.info('[SendGrid unsubscribe] suppressionResult:', suppressionResult);
return {
ok: true,
email: normalizedEmail,
list: listResult,
suppression: suppressionResult,
};}
async function upsertSendgridContact(data: SendgridContactInput) { const apiKey = getRequiredSendgridApiKey(); const listId = getOptionalSendgridListId(); const unsubscribeUrlFieldId = getRequiredUnsubscribeUrlFieldId();
const payload: Record<string, unknown> = {
contacts: [
{
email: data.email.trim().toLowerCase(),
first_name: data.first_name.trim(),
last_name: data.last_name.trim(),
custom_fields: {
[unsubscribeUrlFieldId]: data.unsubscribe_url,
},
},
],
};
if (listId) {
payload.list_ids = [listId];
}
let res: Response;
try {
res = await fetchWithRetry(
'https://api.sendgrid.com/v3/marketing/contacts',
{
method: 'PUT',
headers: {
Authorization: `Bearer ${apiKey}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(payload),
},
{ retries: 3, timeoutMs: 10000 }
);
} catch (err) {
throw new Error(`SendGrid contact upsert fetch failed: ${getErrorDetails(err)}`);
}
if (!res.ok) {
const text = await res.text();
throw new Error(`SendGrid contact upsert failed: ${res.status} ${res.statusText} ${text}`);
}
return res.json();}
async function upsertSendgridContactBatch(contacts: SendgridContactInput[]) { const apiKey = getRequiredSendgridApiKey(); const listId = getOptionalSendgridListId(); const unsubscribeUrlFieldId = getRequiredUnsubscribeUrlFieldId();
const payload: Record<string, unknown> = {
contacts: contacts.map((c) => ({
email: c.email.trim().toLowerCase(),
first_name: c.first_name.trim(),
last_name: c.last_name.trim(),
custom_fields: {
[unsubscribeUrlFieldId]: c.unsubscribe_url,
},
})),
};
if (listId) {
payload.list_ids = [listId];
}
const res = await fetchWithRetry(
'https://api.sendgrid.com/v3/marketing/contacts',
{
method: 'PUT',
headers: {
Authorization: `Bearer ${apiKey}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(payload),
},
{ retries: 3, timeoutMs: 10000 }
);
if (!res.ok) {
const text = await res.text();
throw new Error(`SendGrid batch upsert failed: ${res.status} ${res.statusText} ${text}`);
}
return res.json();}
async function ensureUnsubscribeToken(strapi: any, row: any): Promise { if (row.unsubscribe_token) { return row.unsubscribe_token; }
const newToken = crypto.randomUUID();
await strapi.documents('api::subscription.subscription').update({
documentId: row.documentId,
data: {
unsubscribe_token: newToken,
},
});
// strapi.log.info(`Generated missing unsubscribe_token for subscription ${row.documentId}`);
return newToken;}
async function buildSendgridContactFromSubscription(strapi: any, row: any): Promise { const unsubscribeToken = await ensureUnsubscribeToken(strapi, row);
return {
email: row.email.trim().toLowerCase(),
first_name: row.first_name?.trim() ?? '',
last_name: row.last_name?.trim() ?? '',
unsubscribe_url: buildUnsubscribeUrl(unsubscribeToken),
};}
async function syncSubscriptionRowToSendgrid(strapi: any, row: any) { const email = row.email.trim().toLowerCase();
if (row.subscription_status === 'subscribed') {
const contact = await buildSendgridContactFromSubscription(strapi, row);
await resubscribeSendgridContactByEmail(contact.email);
await upsertSendgridContact(contact);
return {
ok: true,
code: 'resynced-subscribed',
email: contact.email,
subscription_status: row.subscription_status,
};
}
if (row.subscription_status === 'unsubscribed') {
await unsubscribeSendgridContactByEmail(email);
return {
ok: true,
code: 'resynced-unsubscribed',
email,
subscription_status: row.subscription_status,
};
}
return {
ok: false,
code: 'unsupported-status',
email,
subscription_status: row.subscription_status,
};}
export default factories.createCoreService('api::subscription.subscription', ({ strapi }) => ({ async create(params) { const raw = params?.data as SubscriptionInput; const data = normalizeInput(raw);
const existing = await strapi.documents('api::subscription.subscription').findMany({
filters: { email: data.email },
limit: 1,
});
const found = existing[0];
const title = buildTitle(data);
if (!found) {
const unsubscribeToken = crypto.randomUUID();
const unsubscribeUrl = buildUnsubscribeUrl(unsubscribeToken);
const result = await super.create({
...params,
data: {
...params.data,
...data,
title,
subscription_status: 'subscribed',
unsubscribe_token: unsubscribeToken,
resubscribed_at: new Date().toISOString(),
},
});
try {
await resubscribeSendgridContactByEmail(data.email);
await upsertSendgridContact({
...data,
unsubscribe_url: unsubscribeUrl,
});
} catch (err) {
if (err instanceof Error) {
strapi.log.error(`SendGrid sync error on create: ${getErrorDetails(err)}`);
} else {
strapi.log.error(`SendGrid sync error on create: ${JSON.stringify(err)}`);
}
}
return result;
}
if (found.subscription_status === 'subscribed') {
return {
...found,
subscriptionState: 'already-subscribed',
};
}
const newToken = crypto.randomUUID();
const unsubscribeUrl = buildUnsubscribeUrl(newToken);
const updated = await strapi.documents('api::subscription.subscription').update({
documentId: found.documentId,
data: {
title,
first_name: data.first_name,
last_name: data.last_name,
subscription_status: 'subscribed',
unsubscribed_at: null,
resubscribed_at: new Date().toISOString(),
unsubscribe_token: newToken,
},
});
try {
await resubscribeSendgridContactByEmail(data.email);
await upsertSendgridContact({
...data,
unsubscribe_url: unsubscribeUrl,
});
} catch (err) {
if (err instanceof Error) {
strapi.log.error(`SendGrid sync error on resubscribe: ${getErrorDetails(err)}`);
} else {
strapi.log.error(`SendGrid sync error on resubscribe: ${JSON.stringify(err)}`);
}
}
return {
...updated,
subscriptionState: 'resubscribed',
};
},
async unsubscribeByToken(token: string) {
const entries = await strapi.documents('api::subscription.subscription').findMany({
filters: { unsubscribe_token: token },
limit: 1,
});
const found = entries[0];
if (!found) {
return { ok: false, code: 'invalid-token' };
}
if (found.subscription_status === 'unsubscribed') {
return { ok: true, code: 'already-unsubscribed' };
}
const updated = await strapi.documents('api::subscription.subscription').update({
documentId: found.documentId,
data: {
subscription_status: 'unsubscribed',
unsubscribed_at: new Date().toISOString(),
},
});
try {
// strapi.log.info(`[unsubscribeByToken] calling SendGrid unsubscribe for: ${found.email}`);
const sgResult = await unsubscribeSendgridContactByEmail(found.email);
// strapi.log.info(`[unsubscribeByToken] SendGrid result: ${JSON.stringify(sgResult)}`);
} catch (err) {
if (err instanceof Error) {
strapi.log.error(`SendGrid sync error on unsubscribe: ${getErrorDetails(err)}`);
} else {
strapi.log.error(`SendGrid sync error on unsubscribe: ${JSON.stringify(err)}`);
}
}
return { ok: true, code: 'unsubscribed', data: updated };
},
async resyncAllSubscribedToSendgrid() {
const pluginSettings = await getSendgridToolsSettings(strapi);
const syncUnsubscribed = pluginSettings.syncUnsubscribed;
const pageSize = 500;
let start = 0;
let totalSynced = 0;
const allowedStatuses: SubscriptionStatus[] = syncUnsubscribed
? ['subscribed', 'unsubscribed']
: ['subscribed'];
while (true) {
const rows = await strapi.documents('api::subscription.subscription').findMany({
filters: {
subscription_status: {
$in: allowedStatuses,
},
},
sort: { email: 'asc' },
start,
limit: pageSize,
});
if (!rows.length) {
break;
}
try {
const subscribedRows = rows.filter((row: any) => row.subscription_status === 'subscribed');
const unsubscribedRows = rows.filter((row: any) => row.subscription_status === 'unsubscribed');
if (subscribedRows.length > 0) {
const contacts = await Promise.all(
subscribedRows.map((row: any) => buildSendgridContactFromSubscription(strapi, row))
);
for (const contact of contacts) {
await resubscribeSendgridContactByEmail(contact.email);
}
await upsertSendgridContactBatch(contacts);
totalSynced += contacts.length;
}
if (syncUnsubscribed && unsubscribedRows.length > 0) {
for (const row of unsubscribedRows) {
await unsubscribeSendgridContactByEmail(row.email);
totalSynced += 1;
}
}
} catch (err) {
if (err instanceof Error) {
strapi.log.error(`SendGrid batch sync error at start ${start}: ${getErrorDetails(err)}`);
} else {
strapi.log.error(`SendGrid batch sync error at start ${start}: ${JSON.stringify(err)}`);
}
throw err;
}
if (rows.length < pageSize) {
break;
}
start += pageSize;
}
return { ok: true, totalSynced };
},
async resyncOneSubscriptionToSendgrid(documentId: string) {
const row = await strapi.documents('api::subscription.subscription').findOne({
documentId,
});
if (!row) {
return { ok: false, code: 'not-found' };
}
const pluginSettings = await getSendgridToolsSettings(strapi);
if (row.subscription_status === 'subscribed') {
const contact = await buildSendgridContactFromSubscription(strapi, row);
await resubscribeSendgridContactByEmail(contact.email);
await upsertSendgridContact(contact);
return {
ok: true,
code: 'resynced',
documentId,
email: contact.email,
subscription_status: row.subscription_status,
};
}
if (row.subscription_status === 'unsubscribed') {
if (!pluginSettings.syncUnsubscribed) {
return {
ok: false,
code: 'not-subscribed',
message: 'Csak subscribed állapotú rekord szinkronizálható.',
};
}
await unsubscribeSendgridContactByEmail(row.email);
return {
ok: true,
code: 'resynced',
documentId,
email: row.email.trim().toLowerCase(),
subscription_status: row.subscription_status,
};
}
return {
ok: false,
code: 'not-supported-status',
message: `Nem támogatott subscription_status: ${row.subscription_status}`,
};
},}));
