Resend app improvements (#19986)

## Summary
Major overhaul of the `twenty-for-twenty` Resend app to make sync more
reliable, observable, and feature-complete.
### SDK upgrade
- Bumps `twenty-sdk` to `2.0.0` and `twenty-client-sdk` to
`1.23.0-canary.1`
- Pins React back to `^18.2.0` to match the SDK
### Sync engine rewrite
- Splits the single `sync-resend-data` job into 4 staggered cron-driven
logic functions: **Emails**, **Contacts**, **Broadcasts (+ segments +
dependencies)**, **Templates** — each running every 5 minutes on a
different minute offset with per-slot timeouts
- Adds a new `ResendSyncCursor` object + `with-sync-cursor`
orchestration so each step persists its progress, last run timestamp,
and last run status
- Introduces an `INITIAL_SYNC_MODE` app variable +
`resend-initial-sync-mode-monitor` that flips to intermediate sync once
every cursor is empty (intermediate sync only refetches the last 7 days
of emails)
- Stops auto-creating People from Resend contacts; instead backfills
`personId` on Resend contacts/emails by matching existing People by
email
- Renames `on-*-deleted` handlers to `on-*-destroyed` and removes from
Resend on destroy (not soft delete)
- Adds rate-limit retry, paginated `for-each-page`, typed-client, and
existing-IDs lookup helpers

### New objects & fields
- New `ResendTopic` object with relation to `ResendBroadcast` (+
navigation menu item, view, page layout)
- New `ResendSyncCursor` object (step / cursor / last run at / last run
status)
- Adds `html` and `text` fields on `ResendBroadcast`; removes raw
`htmlBody`/`textBody`/`tags` from `ResendEmail`

### New UI
- **Sync Status standalone page** (`ResendSyncStatus` front component +
nav item) showing live cursor / last run state per step
- **Person Resend Email Stats** front component: deliverability rate +
per-status breakdown with progress bars
- **Email Broadcast HTML viewer** front component renders an individual
email against its parent broadcast's HTML; new dedicated **Broadcast
HTML viewer**
- Adds Resend Broadcast record page layout (Home / Preview / Timeline /
Tasks / Notes / Files tabs)
### Tests
- ~25 new unit / integration test files covering sync utilities, cursor
lifecycle, webhook handler, email-stats computation, sync-status page
resolution, and rate-limit retry
- Replaces legacy `fetch-all-paginated` tests with `for-each-page` tests
This commit is contained in:
Raphaël Bosi
2026-04-22 18:17:27 +02:00
committed by GitHub
parent 3ebeb3a3e8
commit 0d996a5629
155 changed files with 8515 additions and 1353 deletions
@@ -20,15 +20,15 @@
},
"dependencies": {
"resend": "^6.12.0",
"twenty-client-sdk": "1.22.0",
"twenty-sdk": "1.22.0"
"twenty-client-sdk": "npm:twenty-client-sdk@1.23.0-canary.1",
"twenty-sdk": "npm:twenty-sdk@2.0.0"
},
"devDependencies": {
"@types/node": "^24.7.2",
"@types/react": "^19.0.0",
"@types/react": "^18.2.0",
"oxlint": "^0.16.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"typescript": "^5.9.3",
"vite-tsconfig-paths": "^4.2.1",
"vitest": "^3.1.1"
@@ -1,6 +1,7 @@
import { CoreApiClient } from 'twenty-client-sdk/core';
import { MetadataApiClient } from 'twenty-client-sdk/metadata';
import { APPLICATION_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
import { APPLICATION_UNIVERSAL_IDENTIFIER } from '@constants/universal-identifiers';
import { isDefined } from '@utils/is-defined';
import { describe, expect, it } from 'vitest';
describe('App installation', () => {
@@ -15,12 +16,12 @@ describe('App installation', () => {
},
});
const app = result.findManyApplications.find(
(a: { universalIdentifier: string }) =>
a.universalIdentifier === APPLICATION_UNIVERSAL_IDENTIFIER,
const matchingApplication = result.findManyApplications.find(
(application: { universalIdentifier: string }) =>
application.universalIdentifier === APPLICATION_UNIVERSAL_IDENTIFIER,
);
expect(app).toBeDefined();
expect(matchingApplication).toBeDefined();
});
});
@@ -34,13 +35,18 @@ describe('CoreApiClient', () => {
id: true,
},
});
expect(created.createNote.id).toBeDefined();
const createdNote = created.createNote;
await client.mutation({
destroyNote: {
__args: { id: created.createNote.id },
id: true,
},
});
expect(createdNote?.id).toBeDefined();
if (isDefined(createdNote)) {
await client.mutation({
destroyNote: {
__args: { id: createdNote.id },
id: true,
},
});
}
});
});
@@ -5,11 +5,12 @@ import {
APP_DISPLAY_NAME,
APPLICATION_UNIVERSAL_IDENTIFIER,
DEFAULT_ROLE_UNIVERSAL_IDENTIFIER,
} from 'src/constants/universal-identifiers';
} from '@constants/universal-identifiers';
import {
INITIAL_SYNC_MODE_APP_VARIABLE_UNIVERSAL_IDENTIFIER,
RESEND_API_KEY_UNIVERSAL_IDENTIFIER,
RESEND_WEBHOOK_SECRET_UNIVERSAL_IDENTIFIER,
} from 'src/modules/resend/constants/universal-identifiers';
} from '@modules/resend/constants/universal-identifiers';
export default defineApplication({
universalIdentifier: APPLICATION_UNIVERSAL_IDENTIFIER,
@@ -27,6 +28,13 @@ export default defineApplication({
description: 'Signing secret for verifying Resend webhook payloads',
isSecret: true,
},
INITIAL_SYNC_MODE: {
universalIdentifier: INITIAL_SYNC_MODE_APP_VARIABLE_UNIVERSAL_IDENTIFIER,
description:
"When 'true', the initial-sync cron runs and the intermediate-sync cron is paused. Flipped to 'false' once every list has completed.",
isSecret: false,
value: 'false',
},
},
defaultRoleUniversalIdentifier: DEFAULT_ROLE_UNIVERSAL_IDENTIFIER,
});
@@ -3,7 +3,7 @@ import { defineRole } from 'twenty-sdk/define';
import {
APP_DISPLAY_NAME,
DEFAULT_ROLE_UNIVERSAL_IDENTIFIER,
} from 'src/constants/universal-identifiers';
} from '@constants/universal-identifiers';
export default defineRole({
universalIdentifier: DEFAULT_ROLE_UNIVERSAL_IDENTIFIER,
@@ -0,0 +1,92 @@
import { describe, expect, it } from 'vitest';
import {
RESEND_SYNC_CRON_PATTERNS,
RESEND_SYNC_SLOT_DEADLINE_SLACK_MS,
RESEND_SYNC_SLOT_TIMEOUT_SECONDS,
} from '@modules/resend/constants/sync-config';
const minutesInOneHour = (pattern: string): number[] => {
const [minuteField, hourField, ...rest] = pattern.split(' ');
expect(hourField).toBe('*');
expect(rest).toEqual(['*', '*', '*']);
const stepMatch = minuteField.match(/^(\d+)-59\/(\d+)$/);
if (stepMatch === null) {
throw new Error(
`Unsupported cron minute field for this test helper: ${minuteField}`,
);
}
const start = Number(stepMatch[1]);
const step = Number(stepMatch[2]);
const minutes: number[] = [];
for (let m = start; m < 60; m += step) {
minutes.push(m);
}
return minutes;
};
describe('RESEND_SYNC_CRON_PATTERNS', () => {
it('runs each cron exactly once per 5-minute window with a unique minute offset', () => {
const offsets = {
EMAILS: minutesInOneHour(RESEND_SYNC_CRON_PATTERNS.EMAILS).map(
(m) => m % 5,
),
CONTACTS: minutesInOneHour(RESEND_SYNC_CRON_PATTERNS.CONTACTS).map(
(m) => m % 5,
),
BROADCASTS: minutesInOneHour(RESEND_SYNC_CRON_PATTERNS.BROADCASTS).map(
(m) => m % 5,
),
TEMPLATES: minutesInOneHour(RESEND_SYNC_CRON_PATTERNS.TEMPLATES).map(
(m) => m % 5,
),
};
expect(new Set(offsets.EMAILS)).toEqual(new Set([0]));
expect(new Set(offsets.CONTACTS)).toEqual(new Set([1]));
expect(new Set(offsets.BROADCASTS)).toEqual(new Set([2]));
expect(new Set(offsets.TEMPLATES)).toEqual(new Set([3]));
const firstOffsets = [
offsets.EMAILS[0],
offsets.CONTACTS[0],
offsets.BROADCASTS[0],
offsets.TEMPLATES[0],
];
expect(new Set(firstOffsets).size).toBe(4);
});
it('fires every five minutes for each cron', () => {
for (const pattern of Object.values(RESEND_SYNC_CRON_PATTERNS)) {
const minutes = minutesInOneHour(pattern);
expect(minutes).toHaveLength(12);
for (let i = 1; i < minutes.length; i++) {
expect(minutes[i] - minutes[i - 1]).toBe(5);
}
}
});
});
describe('RESEND_SYNC_SLOT_TIMEOUT_SECONDS', () => {
it('keeps every 1-minute slot strictly under 60 seconds and the trailing 2-minute slot under 120 seconds', () => {
expect(RESEND_SYNC_SLOT_TIMEOUT_SECONDS.EMAILS).toBeLessThan(60);
expect(RESEND_SYNC_SLOT_TIMEOUT_SECONDS.CONTACTS).toBeLessThan(60);
expect(RESEND_SYNC_SLOT_TIMEOUT_SECONDS.BROADCASTS).toBeLessThan(60);
expect(RESEND_SYNC_SLOT_TIMEOUT_SECONDS.TEMPLATES).toBeLessThan(120);
});
it('leaves enough slack for the deadline check to fire before the hard timeout', () => {
expect(RESEND_SYNC_SLOT_DEADLINE_SLACK_MS).toBeGreaterThan(0);
expect(RESEND_SYNC_SLOT_DEADLINE_SLACK_MS).toBeLessThan(
RESEND_SYNC_SLOT_TIMEOUT_SECONDS.EMAILS * 1_000,
);
});
});
@@ -0,0 +1,31 @@
export const RESEND_PAGE_SIZE = 100;
export const TWENTY_PAGE_SIZE = 100;
export const RATE_LIMIT_MAX_RETRIES = 5;
export const RATE_LIMIT_BASE_DELAY_MS = 1000;
export const RATE_LIMIT_MIN_INTERVAL_MS = 220;
export const SYNC_LOOKUP_PROGRESS = 0.1;
export const INITIAL_SYNC_MODE_ENV_VAR_NAME = 'INITIAL_SYNC_MODE';
export const INTERMEDIATE_SYNC_EMAILS_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000;
export const RESEND_SYNC_CRON_PATTERNS = {
EMAILS: '0-59/5 * * * *',
CONTACTS: '1-59/5 * * * *',
BROADCASTS: '2-59/5 * * * *',
TEMPLATES: '3-59/5 * * * *',
} as const;
export const RESEND_SYNC_SLOT_TIMEOUT_SECONDS = {
EMAILS: 55,
CONTACTS: 55,
BROADCASTS: 55,
TEMPLATES: 115,
} as const;
export const RESEND_SYNC_SLOT_DEADLINE_SLACK_MS = 5_000;
@@ -3,21 +3,32 @@ export const RESEND_API_KEY_UNIVERSAL_IDENTIFIER =
'e5828892-33d5-4532-b796-551df48a07c0';
export const RESEND_WEBHOOK_SECRET_UNIVERSAL_IDENTIFIER =
'b291b241-bd84-4661-8e79-3dc7a63371dd';
export const INITIAL_SYNC_MODE_APP_VARIABLE_UNIVERSAL_IDENTIFIER =
'b2e884a5-ccd6-401b-bcd4-3623babaf409';
// Logic functions
export const SYNC_RESEND_DATA_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER =
'7a3c841f-509e-46f0-b2f1-fb942b716ee3';
export const RESEND_SYNC_BROADCASTS_AND_DEPENDENCIES_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER =
'7fc0e28f-d67f-49ad-a9a5-aa62e9fa938a';
export const RESEND_SYNC_TEMPLATES_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER =
'5a2bf3d1-5dec-4489-8bd8-2e22822ee37b';
export const RESEND_SYNC_CONTACTS_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER =
'b48d0d04-663f-4358-9972-3c69a5553dc8';
export const RESEND_SYNC_EMAILS_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER =
'7fd06109-a23a-4bc1-b734-83dcd0862abe';
export const RESEND_INITIAL_SYNC_MODE_MONITOR_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER =
'd8b6c007-f2a7-49e5-920f-465af4009072';
export const RESEND_WEBHOOK_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER =
'049b7227-3e8b-444b-84aa-939a7e4ca440';
export const ON_RESEND_CONTACT_CREATED_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER =
'656b85b4-71d0-477b-9741-4967d8d88ac9';
export const ON_RESEND_CONTACT_UPDATED_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER =
'7b770cc2-6d31-4f1b-a7db-48d44cf6109b';
export const ON_RESEND_CONTACT_DELETED_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER =
export const ON_RESEND_CONTACT_DESTROYED_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER =
'7a2341e7-d96a-4ba1-b41d-699c73d61081';
export const ON_RESEND_SEGMENT_CREATED_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER =
'ac5b424a-f51d-46c8-a95a-42589fb81676';
export const ON_RESEND_SEGMENT_DELETED_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER =
export const ON_RESEND_SEGMENT_DESTROYED_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER =
'd5e5b6e1-e0d9-45f0-b3d9-6b96417e4ed0';
// Commands
@@ -27,10 +38,16 @@ export const SYNC_RESEND_DATA_COMMAND_UNIVERSAL_IDENTIFIER =
// Front components
export const SYNC_RESEND_DATA_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER =
'96e073b8-e331-40d1-9ec0-137bc921f486';
export const EMAIL_HTML_VIEWER_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER =
'7df0713e-3c16-4cbc-a25f-08cead363941';
export const TEMPLATE_HTML_VIEWER_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER =
'58d79064-1e2a-4500-b8e7-c023cd9835fe';
export const BROADCAST_HTML_VIEWER_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER =
'79ad4e25-4f31-4e71-9716-4a84f4777e8d';
export const EMAIL_BROADCAST_HTML_VIEWER_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER =
'842891d2-5c7c-4a7b-b9d0-e9fbdca06fd3';
export const RESEND_SYNC_STATUS_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER =
'2b9c7e1d-6a4f-4d3b-9c8e-7f5a3d6b2c4e';
export const PERSON_RESEND_EMAIL_STATS_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER =
'a5f3c1e8-2d9b-4f7a-8e6c-1b3d5f7a9c2e';
// Objects
export const RESEND_EMAIL_OBJECT_UNIVERSAL_IDENTIFIER =
@@ -43,6 +60,20 @@ export const RESEND_TEMPLATE_OBJECT_UNIVERSAL_IDENTIFIER =
'85ddb31f-0d1c-4619-bbaf-3d208c1b9fea';
export const RESEND_BROADCAST_OBJECT_UNIVERSAL_IDENTIFIER =
'bebc114f-a8f5-455d-8c6f-e33f20f66967';
export const RESEND_TOPIC_OBJECT_UNIVERSAL_IDENTIFIER =
'e3d6d1a7-09dd-4524-9662-d46a569a1864';
export const RESEND_SYNC_CURSOR_OBJECT_UNIVERSAL_IDENTIFIER =
'3c0f80dc-4b88-4ef6-b08d-4ddd33ba3875';
// Object fields - Resend sync cursor
export const SYNC_CURSOR_STEP_FIELD_UNIVERSAL_IDENTIFIER =
'cfdfc188-0db3-48f9-a750-c095263e9fbc';
export const SYNC_CURSOR_CURSOR_FIELD_UNIVERSAL_IDENTIFIER =
'18eae3e6-cf5b-4094-9c7f-d2db236e2db1';
export const SYNC_CURSOR_LAST_RUN_AT_FIELD_UNIVERSAL_IDENTIFIER =
'a8f1c0e3-3bb6-4f5a-9e2c-7d12e96f4a31';
export const SYNC_CURSOR_LAST_RUN_STATUS_FIELD_UNIVERSAL_IDENTIFIER =
'6d4f8b7a-1c52-4d8a-bf7e-12c3a59f7d6c';
// Object fields - Resend email
export const SUBJECT_FIELD_UNIVERSAL_IDENTIFIER =
@@ -51,10 +82,6 @@ export const FROM_ADDRESS_FIELD_UNIVERSAL_IDENTIFIER =
'c663f57e-0fe3-4066-91df-9eb001bab03a';
export const TO_ADDRESSES_FIELD_UNIVERSAL_IDENTIFIER =
'613e0400-709d-496e-9be4-b6eab8282d2e';
export const HTML_BODY_FIELD_UNIVERSAL_IDENTIFIER =
'd14c29e4-c971-47d3-a1f1-8f459b8d8719';
export const TEXT_BODY_FIELD_UNIVERSAL_IDENTIFIER =
'9d6edd43-903b-4e57-8bd0-8bbd9e914c30';
export const CC_ADDRESSES_FIELD_UNIVERSAL_IDENTIFIER =
'eff53046-039e-444e-9142-33d7cc354ad6';
export const BCC_ADDRESSES_FIELD_UNIVERSAL_IDENTIFIER =
@@ -69,8 +96,6 @@ export const EMAIL_CREATED_AT_FIELD_UNIVERSAL_IDENTIFIER =
'732e6009-67f7-4880-8161-a4310f4df690';
export const SCHEDULED_AT_FIELD_UNIVERSAL_IDENTIFIER =
'3d1b8deb-f102-4c1b-929e-ed7b2647e64b';
export const TAGS_FIELD_UNIVERSAL_IDENTIFIER =
'd6406a2c-a8b6-416e-b351-e1e5ddb3d5fe';
export const EMAIL_LAST_SYNCED_FROM_RESEND_FIELD_UNIVERSAL_IDENTIFIER =
'1b841d96-4318-48f5-aa0d-184d19e9af55';
@@ -145,6 +170,26 @@ export const BROADCAST_SCHEDULED_AT_FIELD_UNIVERSAL_IDENTIFIER =
'abbb4379-d0a5-432a-8c34-0e940242d687';
export const BROADCAST_SENT_AT_FIELD_UNIVERSAL_IDENTIFIER =
'e1e281aa-7ba7-47a0-951d-0f6150a63099';
export const BROADCAST_HTML_FIELD_UNIVERSAL_IDENTIFIER =
'c5bb1861-5d20-4816-89ab-d3d7a35ce1f7';
export const BROADCAST_TEXT_FIELD_UNIVERSAL_IDENTIFIER =
'60de9fcc-7ebd-4c81-ab14-4dd623c5e01e';
// Object fields - Resend topic
export const TOPIC_NAME_FIELD_UNIVERSAL_IDENTIFIER =
'198b6f56-0657-45f6-9c34-c23433f54fc3';
export const TOPIC_DESCRIPTION_FIELD_UNIVERSAL_IDENTIFIER =
'bd366dbc-8cff-4d92-9236-373daba472d1';
export const TOPIC_DEFAULT_SUBSCRIPTION_FIELD_UNIVERSAL_IDENTIFIER =
'58e26e08-cc90-447d-9cf4-e22afaf64679';
export const TOPIC_VISIBILITY_FIELD_UNIVERSAL_IDENTIFIER =
'fe7ced6a-1978-4311-bb5e-b2d55c300d82';
export const TOPIC_RESEND_ID_FIELD_UNIVERSAL_IDENTIFIER =
'af8a3e28-c9dd-4c61-8f93-b425cf6de452';
export const TOPIC_CREATED_AT_FIELD_UNIVERSAL_IDENTIFIER =
'4cffe6e2-8cf2-4432-bafe-603847e7e283';
export const TOPIC_LAST_SYNCED_FROM_RESEND_FIELD_UNIVERSAL_IDENTIFIER =
'8cbbe9d6-151b-4594-8441-dbce3cb3efde';
// Relation fields
export const SEGMENT_ON_RESEND_BROADCAST_FIELD_UNIVERSAL_IDENTIFIER =
@@ -171,6 +216,10 @@ export const PERSON_ON_RESEND_EMAIL_FIELD_UNIVERSAL_IDENTIFIER =
'cb08e862-8114-480e-986b-8f50fe49de41';
export const RESEND_EMAILS_ON_CONTACT_FIELD_UNIVERSAL_IDENTIFIER =
'd8b69019-ca10-4599-b099-ecfc891d6438';
export const TOPIC_ON_RESEND_BROADCAST_FIELD_UNIVERSAL_IDENTIFIER =
'05c7f816-4c1d-4d98-80e5-e24c6679b363';
export const RESEND_BROADCASTS_ON_TOPIC_FIELD_UNIVERSAL_IDENTIFIER =
'90c64f8d-1352-4f06-bd24-c13d47f0584d';
// Views
export const RESEND_BROADCAST_VIEW_UNIVERSAL_IDENTIFIER =
@@ -183,6 +232,8 @@ export const RESEND_CONTACT_VIEW_UNIVERSAL_IDENTIFIER =
'3d710924-5f47-4ac7-ba5e-28d3be9ee004';
export const RESEND_EMAIL_VIEW_UNIVERSAL_IDENTIFIER =
'43571c5b-f71d-47bf-95b3-8741b2201315';
export const RESEND_TOPIC_VIEW_UNIVERSAL_IDENTIFIER =
'06755bbf-a1b0-48ad-84d1-37e33cdca872';
// View fields - Resend broadcast view
export const RESEND_BROADCAST_VIEW_NAME_FIELD_UNIVERSAL_IDENTIFIER =
@@ -201,6 +252,22 @@ export const RESEND_BROADCAST_VIEW_SEGMENT_FIELD_UNIVERSAL_IDENTIFIER =
'4d7eb630-9f44-4090-a887-7fb08e0c49ed';
export const RESEND_BROADCAST_VIEW_EMAILS_FIELD_UNIVERSAL_IDENTIFIER =
'e1726752-fed7-4e9b-b395-e47b60f3d56d';
export const RESEND_BROADCAST_VIEW_TOPIC_FIELD_UNIVERSAL_IDENTIFIER =
'13d2f272-4670-4021-8443-eba57f8d3423';
// View fields - Resend topic view
export const RESEND_TOPIC_VIEW_NAME_FIELD_UNIVERSAL_IDENTIFIER =
'b78f30af-4973-4905-ae6c-d44e0febe1b9';
export const RESEND_TOPIC_VIEW_DESCRIPTION_FIELD_UNIVERSAL_IDENTIFIER =
'4c71eabd-f5c4-4644-a67d-0ece699dd7d4';
export const RESEND_TOPIC_VIEW_VISIBILITY_FIELD_UNIVERSAL_IDENTIFIER =
'db96323c-9d07-47d9-9773-14068c513c75';
export const RESEND_TOPIC_VIEW_DEFAULT_SUBSCRIPTION_FIELD_UNIVERSAL_IDENTIFIER =
'0ffe1090-a4e1-4c4f-ad9f-03496d29dd55';
export const RESEND_TOPIC_VIEW_CREATED_AT_FIELD_UNIVERSAL_IDENTIFIER =
'c4c206e1-d9ca-4db1-abe0-7ccd9914df7b';
export const RESEND_TOPIC_VIEW_BROADCASTS_FIELD_UNIVERSAL_IDENTIFIER =
'53d704d6-196c-44a3-9ddc-d91bf6194eb0';
// View fields - Resend template view
export const RESEND_TEMPLATE_VIEW_NAME_FIELD_UNIVERSAL_IDENTIFIER =
@@ -245,6 +312,8 @@ export const RESEND_CONTACT_VIEW_EMAILS_FIELD_UNIVERSAL_IDENTIFIER =
// View fields - Resend email view
export const RESEND_EMAIL_VIEW_SUBJECT_FIELD_UNIVERSAL_IDENTIFIER =
'3a3ee801-6dfa-43b4-ba22-5c079a2d238d';
export const RESEND_EMAIL_VIEW_TO_ADDRESS_FIELD_UNIVERSAL_IDENTIFIER =
'4ddb4d8e-f597-418a-ac92-a8dbd93dbee6';
export const RESEND_EMAIL_VIEW_FROM_ADDRESS_FIELD_UNIVERSAL_IDENTIFIER =
'486e23dd-3d8b-42d8-86a8-146d5fd7aaf0';
export const RESEND_EMAIL_VIEW_LAST_EVENT_FIELD_UNIVERSAL_IDENTIFIER =
@@ -257,6 +326,8 @@ export const RESEND_EMAIL_VIEW_CONTACT_FIELD_UNIVERSAL_IDENTIFIER =
'abee6dcd-4754-45b2-ae77-d42bec85afad';
export const RESEND_EMAIL_VIEW_BROADCAST_FIELD_UNIVERSAL_IDENTIFIER =
'8d7df695-5ffb-46b7-9a57-ac979988351f';
export const RESEND_EMAIL_VIEW_TO_ADDRESSES_FIELD_UNIVERSAL_IDENTIFIER =
'e52cc330-bd21-460f-a539-7a1b8a119dc8';
// Navigation menu items
export const RESEND_FOLDER_NAVIGATION_MENU_ITEM_UNIVERSAL_IDENTIFIER =
@@ -271,6 +342,8 @@ export const RESEND_BROADCAST_NAVIGATION_MENU_ITEM_UNIVERSAL_IDENTIFIER =
'cb11a15d-2116-4dd2-9f7d-88ffd2271620';
export const RESEND_CONTACT_NAVIGATION_MENU_ITEM_UNIVERSAL_IDENTIFIER =
'7cf30c1e-3f3c-4250-9141-a6584dc6697b';
export const RESEND_TOPIC_NAVIGATION_MENU_ITEM_UNIVERSAL_IDENTIFIER =
'398560f0-3cd2-4749-99db-8096ab43e2f5';
// Page layouts - Resend template record page
export const RESEND_TEMPLATE_RECORD_PAGE_LAYOUT_UNIVERSAL_IDENTIFIER =
@@ -300,6 +373,34 @@ export const RESEND_TEMPLATE_RECORD_PAGE_FILES_TAB_UNIVERSAL_IDENTIFIER =
export const RESEND_TEMPLATE_RECORD_PAGE_FILES_WIDGET_UNIVERSAL_IDENTIFIER =
'3059a217-5322-4744-9ed0-4757591bba1c';
// Page layouts - Resend broadcast record page
export const RESEND_BROADCAST_RECORD_PAGE_LAYOUT_UNIVERSAL_IDENTIFIER =
'bb61745d-f760-4d1b-aab6-be286a4e84c9';
export const RESEND_BROADCAST_RECORD_PAGE_HOME_TAB_UNIVERSAL_IDENTIFIER =
'b19ff920-72f6-4b16-9049-da27f35bc6c8';
export const RESEND_BROADCAST_RECORD_PAGE_HOME_FIELDS_WIDGET_UNIVERSAL_IDENTIFIER =
'804f0a5d-32e2-414a-bb2a-b39cc98d1813';
export const RESEND_BROADCAST_RECORD_PAGE_PREVIEW_TAB_UNIVERSAL_IDENTIFIER =
'bb2d137f-a07d-4e8e-9f99-5d2d401b5de5';
export const RESEND_BROADCAST_RECORD_PAGE_PREVIEW_WIDGET_UNIVERSAL_IDENTIFIER =
'cd87eda6-f1d2-43ce-a8c0-b1d055d58134';
export const RESEND_BROADCAST_RECORD_PAGE_TIMELINE_TAB_UNIVERSAL_IDENTIFIER =
'0092e23d-72b4-4e24-90c2-c2bee1c1ceb7';
export const RESEND_BROADCAST_RECORD_PAGE_TIMELINE_WIDGET_UNIVERSAL_IDENTIFIER =
'9b70da94-185a-406a-8552-1013a2df58cb';
export const RESEND_BROADCAST_RECORD_PAGE_TASKS_TAB_UNIVERSAL_IDENTIFIER =
'e7d41836-d4bf-47bb-9016-0e22571eb59f';
export const RESEND_BROADCAST_RECORD_PAGE_TASKS_WIDGET_UNIVERSAL_IDENTIFIER =
'59eb58c9-324a-4244-bbf0-d841b4dcaec2';
export const RESEND_BROADCAST_RECORD_PAGE_NOTES_TAB_UNIVERSAL_IDENTIFIER =
'44347daf-da78-4868-ab47-70643994cb8b';
export const RESEND_BROADCAST_RECORD_PAGE_NOTES_WIDGET_UNIVERSAL_IDENTIFIER =
'6dbc85dc-3f3e-4f00-94a7-0cfac5d63fca';
export const RESEND_BROADCAST_RECORD_PAGE_FILES_TAB_UNIVERSAL_IDENTIFIER =
'7dfffff1-9dbf-4abd-af23-49ea60c00bf9';
export const RESEND_BROADCAST_RECORD_PAGE_FILES_WIDGET_UNIVERSAL_IDENTIFIER =
'9ea88269-d202-469e-aec5-07409ef22987';
// Page layouts - Resend email record page
export const RESEND_EMAIL_RECORD_PAGE_LAYOUT_UNIVERSAL_IDENTIFIER =
'e481afa9-f100-4d88-959d-d4b3518583a2';
@@ -308,9 +409,9 @@ export const RESEND_EMAIL_RECORD_PAGE_HOME_TAB_UNIVERSAL_IDENTIFIER =
export const RESEND_EMAIL_RECORD_PAGE_HOME_FIELDS_WIDGET_UNIVERSAL_IDENTIFIER =
'4eebad57-eb42-4b69-85a6-2e7c1d365b94';
export const RESEND_EMAIL_RECORD_PAGE_PREVIEW_TAB_UNIVERSAL_IDENTIFIER =
'50299e13-3652-4059-ae1e-db512869d20b';
'074a8e3e-33b7-47de-b8da-122dccac06ed';
export const RESEND_EMAIL_RECORD_PAGE_PREVIEW_WIDGET_UNIVERSAL_IDENTIFIER =
'10bedce3-3e4f-4639-8500-e2035241f364';
'4842262b-eb12-4a51-b40d-faee1c563203';
export const RESEND_EMAIL_RECORD_PAGE_TIMELINE_TAB_UNIVERSAL_IDENTIFIER =
'e6c548d4-c371-4b4c-b59c-5b5f4fe50b11';
export const RESEND_EMAIL_RECORD_PAGE_TIMELINE_WIDGET_UNIVERSAL_IDENTIFIER =
@@ -327,3 +428,15 @@ export const RESEND_EMAIL_RECORD_PAGE_FILES_TAB_UNIVERSAL_IDENTIFIER =
'c2f8a3d1-7e49-4b56-9c0a-8d1e5f3b7a92';
export const RESEND_EMAIL_RECORD_PAGE_FILES_WIDGET_UNIVERSAL_IDENTIFIER =
'b9d4e6f2-1a38-4c75-8b0d-3f7a9c2e5d14';
// Page layouts - Resend sync status standalone page
export const RESEND_SYNC_STATUS_PAGE_LAYOUT_UNIVERSAL_IDENTIFIER =
'556a2d62-b21f-4958-8176-27c3ef50e00d';
export const RESEND_SYNC_STATUS_PAGE_HOME_TAB_UNIVERSAL_IDENTIFIER =
'026d6c62-4afe-4ce0-afcb-2b82c2f71b0b';
export const RESEND_SYNC_STATUS_PAGE_WIDGET_UNIVERSAL_IDENTIFIER =
'a93cf282-e9b8-42e7-a4f5-1a5181b83786';
// Navigation menu items (additional)
export const RESEND_SYNC_STATUS_NAVIGATION_MENU_ITEM_UNIVERSAL_IDENTIFIER =
'8cd224de-885b-446b-9b81-d0e97e4e531c';
@@ -0,0 +1,108 @@
export type ThemeColor =
| 'red'
| 'ruby'
| 'crimson'
| 'tomato'
| 'orange'
| 'amber'
| 'yellow'
| 'lime'
| 'grass'
| 'green'
| 'jade'
| 'mint'
| 'turquoise'
| 'cyan'
| 'sky'
| 'blue'
| 'iris'
| 'violet'
| 'purple'
| 'plum'
| 'pink'
| 'bronze'
| 'gold'
| 'brown'
| 'gray';
export type ResendEmailStatus =
| 'SENT'
| 'DELIVERED'
| 'DELIVERY_DELAYED'
| 'COMPLAINED'
| 'BOUNCED'
| 'OPENED'
| 'CLICKED'
| 'SCHEDULED'
| 'QUEUED'
| 'FAILED'
| 'CANCELED'
| 'RECEIVED'
| 'SUPPRESSED';
export type EmailStatusGroup = 'reached' | 'failed' | 'inFlight' | 'other';
export const EMAIL_STATUS_GROUP_BY_STATUS: Record<
ResendEmailStatus,
EmailStatusGroup
> = {
DELIVERED: 'reached',
OPENED: 'reached',
CLICKED: 'reached',
BOUNCED: 'failed',
FAILED: 'failed',
COMPLAINED: 'failed',
SUPPRESSED: 'failed',
SENT: 'inFlight',
QUEUED: 'inFlight',
SCHEDULED: 'inFlight',
DELIVERY_DELAYED: 'inFlight',
CANCELED: 'other',
RECEIVED: 'other',
};
export type EmailStatusMeta = {
label: string;
color: ThemeColor;
};
export const EMAIL_STATUS_META_BY_STATUS: Record<
ResendEmailStatus,
EmailStatusMeta
> = {
SENT: { label: 'Sent', color: 'blue' },
DELIVERED: { label: 'Delivered', color: 'green' },
DELIVERY_DELAYED: { label: 'Delivery Delayed', color: 'yellow' },
COMPLAINED: { label: 'Complained', color: 'orange' },
BOUNCED: { label: 'Bounced', color: 'red' },
OPENED: { label: 'Opened', color: 'turquoise' },
CLICKED: { label: 'Clicked', color: 'sky' },
SCHEDULED: { label: 'Scheduled', color: 'gray' },
QUEUED: { label: 'Queued', color: 'gray' },
FAILED: { label: 'Failed', color: 'red' },
CANCELED: { label: 'Canceled', color: 'gray' },
RECEIVED: { label: 'Received', color: 'blue' },
SUPPRESSED: { label: 'Suppressed', color: 'red' },
};
export const RESEND_EMAIL_STATUS_DISPLAY_ORDER: ReadonlyArray<ResendEmailStatus> =
[
'DELIVERED',
'OPENED',
'CLICKED',
'SENT',
'QUEUED',
'SCHEDULED',
'DELIVERY_DELAYED',
'BOUNCED',
'COMPLAINED',
'SUPPRESSED',
'FAILED',
'CANCELED',
'RECEIVED',
];
export const isResendEmailStatus = (
value: string | null | undefined,
): value is ResendEmailStatus =>
typeof value === 'string' && value in EMAIL_STATUS_GROUP_BY_STATUS;
@@ -0,0 +1,265 @@
import { defineFrontComponent } from 'twenty-sdk/define';
import {
Callout,
H2Title,
IconAlertCircle,
IconMail,
IconRefresh,
themeCssVariables,
} from 'twenty-sdk/ui';
import { isDefined } from '@utils/is-defined';
import { PERSON_RESEND_EMAIL_STATS_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER } from '@modules/resend/constants/universal-identifiers';
import {
EMAIL_STATUS_META_BY_STATUS,
RESEND_EMAIL_STATUS_DISPLAY_ORDER,
type ThemeColor,
} from '@modules/resend/email-stats/constants/email-status-groups';
import { usePersonResendEmailStats } from '@modules/resend/email-stats/hooks/usePersonResendEmailStats';
const getDeliverabilityColor = (rate: number): ThemeColor => {
if (rate >= 0.95) return 'green';
if (rate >= 0.8) return 'yellow';
return 'red';
};
const formatRate = (rate: number): string => `${Math.round(rate * 100)}%`;
const formatPercentage = (value: number, total: number): string => {
if (total === 0) return '0%';
const pct = (value / total) * 100;
return pct >= 10 ? `${pct.toFixed(0)}%` : `${pct.toFixed(1)}%`;
};
type StatusPillProps = {
color: ThemeColor;
text: string;
};
const StatusPill = ({ color, text }: StatusPillProps) => (
<span
style={{
display: 'inline-flex',
alignItems: 'center',
padding: `0 ${themeCssVariables.spacing[2]}`,
height: themeCssVariables.spacing[5],
borderRadius: themeCssVariables.border.radius.sm,
background: themeCssVariables.tag.background[color],
color: themeCssVariables.tag.text[color],
fontSize: themeCssVariables.font.size.xs,
fontWeight: 500,
lineHeight: 1,
whiteSpace: 'nowrap',
}}
>
{text}
</span>
);
const PROGRESS_BAR_HEIGHT = '8px';
type MiniProgressBarProps = {
value: number;
color: ThemeColor;
};
const MiniProgressBar = ({ value, color }: MiniProgressBarProps) => {
const clamped = Math.max(0, Math.min(100, value));
return (
<div
role="progressbar"
aria-valuenow={Math.round(clamped)}
aria-valuemin={0}
aria-valuemax={100}
style={{
position: 'relative',
width: '100%',
height: PROGRESS_BAR_HEIGHT,
borderRadius: themeCssVariables.border.radius.pill,
background: themeCssVariables.tag.background[color],
overflow: 'hidden',
}}
>
<div
style={{
width: `${clamped}%`,
height: '100%',
borderRadius: themeCssVariables.border.radius.pill,
background: themeCssVariables.tag.text[color],
transition: 'width 0.3s linear',
}}
/>
</div>
);
};
const getStyles = (): Record<string, React.CSSProperties> => ({
container: {
fontFamily: themeCssVariables.font.family,
fontSize: themeCssVariables.font.size.sm,
color: themeCssVariables.font.color.primary,
display: 'flex',
flexDirection: 'column',
gap: themeCssVariables.spacing[3],
boxSizing: 'border-box',
},
card: {
padding: themeCssVariables.spacing[3],
borderRadius: themeCssVariables.border.radius.md,
background: themeCssVariables.background.secondary,
border: `1px solid ${themeCssVariables.border.color.light}`,
display: 'flex',
flexDirection: 'column',
gap: themeCssVariables.spacing[2],
},
cardHeader: {
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
gap: themeCssVariables.spacing[2],
},
h2TitleNoMargin: {
display: 'flex',
marginBottom: `calc(-1 * ${themeCssVariables.spacing[4]})`,
},
rateRow: {
display: 'flex',
alignItems: 'center',
gap: themeCssVariables.spacing[2],
color: themeCssVariables.font.color.secondary,
},
rateMeta: {
fontSize: themeCssVariables.font.size.xs,
color: themeCssVariables.font.color.tertiary,
},
legend: {
display: 'flex',
flexDirection: 'column',
gap: themeCssVariables.spacing[2],
},
statusRow: {
display: 'grid',
gridTemplateColumns: 'minmax(120px, max-content) 1fr auto',
alignItems: 'center',
gap: themeCssVariables.spacing[2],
},
statusCount: {
fontVariantNumeric: 'tabular-nums',
color: themeCssVariables.font.color.secondary,
whiteSpace: 'nowrap',
},
});
const PersonResendEmailStats = () => {
const { stats, loading, error } = usePersonResendEmailStats();
const styles = getStyles();
if (loading) {
return (
<div style={styles.container}>
<Callout
variant="neutral"
title="Loading email stats"
description="Computing per-status counts and deliverability\u2026"
Icon={IconRefresh}
/>
</div>
);
}
if (isDefined(error)) {
return (
<div style={styles.container}>
<Callout
variant="error"
title="Failed to load email stats"
description={error}
Icon={IconAlertCircle}
/>
</div>
);
}
if (stats.total === 0) {
return (
<div style={styles.container}>
<Callout
variant="info"
title="No Resend emails yet"
description="This person has no linked Resend emails to compute stats from."
Icon={IconMail}
/>
</div>
);
}
const deliverabilityRate = stats.deliverabilityRate;
const deliverabilityDenominator =
stats.groupCounts.reached + stats.groupCounts.failed;
const rateColor = isDefined(deliverabilityRate)
? getDeliverabilityColor(deliverabilityRate)
: 'gray';
const rateLabel = isDefined(deliverabilityRate)
? formatRate(deliverabilityRate)
: 'N/A';
return (
<div style={styles.container}>
<div style={styles.card}>
<div style={styles.cardHeader}>
<div style={styles.h2TitleNoMargin}>
<H2Title title="Deliverability rate" />
</div>
<StatusPill color={rateColor} text={rateLabel} />
</div>
<MiniProgressBar
value={isDefined(deliverabilityRate) ? deliverabilityRate * 100 : 0}
color={rateColor}
/>
<div style={styles.rateMeta}>
{stats.groupCounts.reached} delivered out of{' '}
{deliverabilityDenominator} concluded ({stats.total} total emails)
</div>
</div>
<div style={styles.card}>
<div style={styles.h2TitleNoMargin}>
<H2Title title="Status breakdown" />
</div>
<div style={styles.legend}>
{RESEND_EMAIL_STATUS_DISPLAY_ORDER.map((status) => {
const count = stats.countsByStatus[status];
if (count === 0) return null;
const meta = EMAIL_STATUS_META_BY_STATUS[status];
const percentage = (count / stats.total) * 100;
return (
<div key={status} style={styles.statusRow}>
<StatusPill color={meta.color} text={meta.label} />
<MiniProgressBar value={percentage} color={meta.color} />
<span style={styles.statusCount}>
{count} ({formatPercentage(count, stats.total)})
</span>
</div>
);
})}
</div>
</div>
</div>
);
};
export default defineFrontComponent({
universalIdentifier:
PERSON_RESEND_EMAIL_STATS_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER,
name: 'Person Resend Email Stats',
description:
"Shows the breakdown of a person's linked Resend emails by last event and computes the deliverability rate.",
component: PersonResendEmailStats,
});
@@ -0,0 +1,126 @@
import { useEffect, useState } from 'react';
import { CoreApiClient } from 'twenty-client-sdk/core';
import { useRecordId } from 'twenty-sdk/front-component';
import { isDefined } from '@utils/is-defined';
import { computeEmailStats, type EmailStats } from '@modules/resend/email-stats/utils/compute-email-stats';
import { extractConnection } from '@modules/resend/shared/utils/typed-client';
const PAGE_SIZE = 100;
const MAX_PAGES = 50;
type ResendEmailLastEventNode = {
lastEvent?: string | null;
};
export type PersonResendEmailStatsState = {
stats: EmailStats;
loading: boolean;
error: string | null;
};
const buildEmptyStats = (): EmailStats => computeEmailStats([]);
export const usePersonResendEmailStats = (): PersonResendEmailStatsState => {
const recordId = useRecordId();
const [state, setState] = useState<PersonResendEmailStatsState>({
stats: buildEmptyStats(),
loading: true,
error: null,
});
useEffect(() => {
if (!isDefined(recordId)) {
setState({
stats: buildEmptyStats(),
loading: false,
error: 'No record ID',
});
return;
}
let cancelled = false;
const load = async () => {
setState({ stats: buildEmptyStats(), loading: true, error: null });
try {
const client = new CoreApiClient();
const collected: ResendEmailLastEventNode[] = [];
let afterCursor: string | undefined;
for (let page = 0; page < MAX_PAGES; page += 1) {
const queryArgs: Record<string, unknown> = {
filter: { personId: { eq: recordId } },
first: PAGE_SIZE,
};
if (isDefined(afterCursor)) {
queryArgs.after = afterCursor;
}
const queryResult = await client.query({
resendEmails: {
__args: queryArgs,
pageInfo: {
hasNextPage: true,
endCursor: true,
},
edges: {
node: {
lastEvent: true,
},
},
},
});
const connection = extractConnection<ResendEmailLastEventNode>(
queryResult,
'resendEmails',
);
for (const edge of connection.edges) {
collected.push(edge.node);
}
const hasNextPage = connection.pageInfo?.hasNextPage ?? false;
const endCursor = connection.pageInfo?.endCursor;
if (!hasNextPage || !isDefined(endCursor)) {
break;
}
afterCursor = endCursor;
}
if (!cancelled) {
setState({
stats: computeEmailStats(collected),
loading: false,
error: null,
});
}
} catch (fetchError) {
if (!cancelled) {
setState({
stats: buildEmptyStats(),
loading: false,
error:
fetchError instanceof Error
? fetchError.message
: String(fetchError),
});
}
}
};
load();
return () => {
cancelled = true;
};
}, [recordId]);
return state;
};
@@ -0,0 +1,106 @@
import { describe, expect, it } from 'vitest';
import { computeEmailStats } from '@modules/resend/email-stats/utils/compute-email-stats';
describe('computeEmailStats', () => {
it('returns zeroed counts and a null deliverability rate for an empty list', () => {
const stats = computeEmailStats([]);
expect(stats.total).toBe(0);
expect(stats.deliverabilityRate).toBeNull();
expect(stats.groupCounts).toEqual({
reached: 0,
failed: 0,
inFlight: 0,
other: 0,
});
expect(stats.countsByStatus.DELIVERED).toBe(0);
expect(stats.countsByStatus.BOUNCED).toBe(0);
});
it('returns a deliverability rate of 1 when all emails are delivered', () => {
const stats = computeEmailStats([
{ lastEvent: 'DELIVERED' },
{ lastEvent: 'DELIVERED' },
{ lastEvent: 'OPENED' },
{ lastEvent: 'CLICKED' },
]);
expect(stats.total).toBe(4);
expect(stats.groupCounts.reached).toBe(4);
expect(stats.groupCounts.failed).toBe(0);
expect(stats.deliverabilityRate).toBe(1);
});
it('returns a deliverability rate of 0 when every email failed', () => {
const stats = computeEmailStats([
{ lastEvent: 'BOUNCED' },
{ lastEvent: 'FAILED' },
{ lastEvent: 'COMPLAINED' },
{ lastEvent: 'SUPPRESSED' },
]);
expect(stats.total).toBe(4);
expect(stats.groupCounts.reached).toBe(0);
expect(stats.groupCounts.failed).toBe(4);
expect(stats.deliverabilityRate).toBe(0);
});
it('returns a null deliverability rate when only in-flight statuses are present', () => {
const stats = computeEmailStats([
{ lastEvent: 'SENT' },
{ lastEvent: 'QUEUED' },
{ lastEvent: 'SCHEDULED' },
{ lastEvent: 'DELIVERY_DELAYED' },
]);
expect(stats.total).toBe(4);
expect(stats.groupCounts.inFlight).toBe(4);
expect(stats.deliverabilityRate).toBeNull();
});
it('aggregates a mixed list correctly', () => {
const stats = computeEmailStats([
{ lastEvent: 'DELIVERED' },
{ lastEvent: 'DELIVERED' },
{ lastEvent: 'OPENED' },
{ lastEvent: 'BOUNCED' },
{ lastEvent: 'FAILED' },
{ lastEvent: 'SENT' },
{ lastEvent: 'CANCELED' },
]);
expect(stats.total).toBe(7);
expect(stats.countsByStatus.DELIVERED).toBe(2);
expect(stats.countsByStatus.OPENED).toBe(1);
expect(stats.countsByStatus.BOUNCED).toBe(1);
expect(stats.countsByStatus.FAILED).toBe(1);
expect(stats.countsByStatus.SENT).toBe(1);
expect(stats.countsByStatus.CANCELED).toBe(1);
expect(stats.groupCounts).toEqual({
reached: 3,
failed: 2,
inFlight: 1,
other: 1,
});
expect(stats.deliverabilityRate).toBeCloseTo(3 / 5);
});
it('ignores unknown, null, and undefined statuses', () => {
const stats = computeEmailStats([
{ lastEvent: 'DELIVERED' },
{ lastEvent: 'BOUNCED' },
{ lastEvent: null },
{ lastEvent: undefined },
{ lastEvent: 'NOT_A_REAL_STATUS' },
{},
]);
expect(stats.total).toBe(2);
expect(stats.groupCounts.reached).toBe(1);
expect(stats.groupCounts.failed).toBe(1);
expect(stats.deliverabilityRate).toBe(0.5);
});
});
@@ -0,0 +1,71 @@
import {
EMAIL_STATUS_GROUP_BY_STATUS,
isResendEmailStatus,
type EmailStatusGroup,
type ResendEmailStatus,
} from '@modules/resend/email-stats/constants/email-status-groups';
export type EmailStatsInput = ReadonlyArray<{
lastEvent?: string | null;
}>;
export type EmailStats = {
total: number;
countsByStatus: Record<ResendEmailStatus, number>;
groupCounts: Record<EmailStatusGroup, number>;
deliverabilityRate: number | null;
};
const buildEmptyStatusCounts = (): Record<ResendEmailStatus, number> => ({
SENT: 0,
DELIVERED: 0,
DELIVERY_DELAYED: 0,
COMPLAINED: 0,
BOUNCED: 0,
OPENED: 0,
CLICKED: 0,
SCHEDULED: 0,
QUEUED: 0,
FAILED: 0,
CANCELED: 0,
RECEIVED: 0,
SUPPRESSED: 0,
});
const buildEmptyGroupCounts = (): Record<EmailStatusGroup, number> => ({
reached: 0,
failed: 0,
inFlight: 0,
other: 0,
});
export const computeEmailStats = (emails: EmailStatsInput): EmailStats => {
const countsByStatus = buildEmptyStatusCounts();
const groupCounts = buildEmptyGroupCounts();
let total = 0;
for (const email of emails) {
const status = email.lastEvent;
if (!isResendEmailStatus(status)) {
continue;
}
countsByStatus[status] += 1;
groupCounts[EMAIL_STATUS_GROUP_BY_STATUS[status]] += 1;
total += 1;
}
const deliverabilityDenominator = groupCounts.reached + groupCounts.failed;
const deliverabilityRate =
deliverabilityDenominator > 0
? groupCounts.reached / deliverabilityDenominator
: null;
return {
total,
countsByStatus,
groupCounts,
deliverabilityRate,
};
};
@@ -1,27 +1,40 @@
import { isNonEmptyString } from '@sniptt/guards';
import { isDefined } from 'twenty-shared/utils';
import { isDefined } from '@utils/is-defined';
import { themeCssVariables } from 'twenty-sdk/ui';
type HtmlPreviewProps = {
html: string | null | undefined;
};
// Styles are computed lazily inside the component body because the SDK
// mocks `twenty-sdk/ui` at manifest-build time, which leaves
// `themeCssVariables` undefined during static module evaluation.
const getStyles = (): Record<string, React.CSSProperties> => ({
emptyState: {
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
height: '100%',
padding: themeCssVariables.spacing[4],
fontFamily: themeCssVariables.font.family,
fontSize: themeCssVariables.font.size.sm,
color: themeCssVariables.font.color.tertiary,
background: themeCssVariables.background.secondary,
boxSizing: 'border-box',
},
iframe: {
width: '100%',
height: '100%',
border: 'none',
background: themeCssVariables.background.primary,
},
});
export const HtmlPreview = ({ html }: HtmlPreviewProps) => {
const styles = getStyles();
if (!isDefined(html) || !isNonEmptyString(html)) {
return (
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
height: '100%',
color: '#999',
fontFamily: 'sans-serif',
fontSize: '14px',
}}
>
No HTML content available
</div>
);
return <div style={styles.emptyState}>No HTML content available</div>;
}
return (
@@ -29,11 +42,7 @@ export const HtmlPreview = ({ html }: HtmlPreviewProps) => {
srcDoc={html}
sandbox=""
title="Email HTML preview"
style={{
width: '100%',
height: '100%',
border: 'none',
}}
style={styles.iframe}
/>
);
};
@@ -1,54 +1,73 @@
import { isDefined } from 'twenty-shared/utils';
import { isDefined } from '@utils/is-defined';
import { Callout, IconAlertCircle, themeCssVariables } from 'twenty-sdk/ui';
import { HtmlPreview } from 'src/modules/resend/html-viewer/components/HtmlPreview';
import { useRecordHtml } from 'src/modules/resend/html-viewer/hooks/useRecordHtml';
import { HtmlPreview } from '@modules/resend/html-viewer/components/HtmlPreview';
import { useRecordHtml } from '@modules/resend/html-viewer/hooks/useRecordHtml';
type RecordHtmlViewerProps = {
objectName: string;
loadingText: string;
};
// Styles are computed lazily inside the component body because the SDK
// mocks `twenty-sdk/ui` at manifest-build time, which leaves
// `themeCssVariables` undefined during static module evaluation.
const getStyles = (): Record<string, React.CSSProperties> => {
const stateContainer: React.CSSProperties = {
padding: themeCssVariables.spacing[4],
fontFamily: themeCssVariables.font.family,
height: '100%',
boxSizing: 'border-box',
};
return {
stateContainer,
loader: {
...stateContainer,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: themeCssVariables.font.color.tertiary,
fontSize: themeCssVariables.font.size.sm,
},
previewWrapper: {
width: '100%',
height: '100%',
background: themeCssVariables.background.secondary,
border: `1px solid ${themeCssVariables.border.color.light}`,
borderRadius: themeCssVariables.border.radius.md,
overflow: 'hidden',
boxSizing: 'border-box',
},
};
};
export const RecordHtmlViewer = ({
objectName,
loadingText,
}: RecordHtmlViewerProps) => {
const { html, loading, error } = useRecordHtml(objectName);
const styles = getStyles();
if (loading) {
return (
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
height: '100%',
color: '#999',
fontFamily: 'sans-serif',
fontSize: '14px',
}}
>
{loadingText}
</div>
);
return <div style={styles.loader}>{loadingText}</div>;
}
if (isDefined(error)) {
return (
<div
style={{
padding: '16px',
color: '#999',
fontFamily: 'sans-serif',
fontSize: '13px',
}}
>
<div>{error}</div>
<div style={styles.stateContainer}>
<Callout
variant="error"
title="Failed to load content"
description={error}
Icon={IconAlertCircle}
/>
</div>
);
}
return (
<div style={{ width: '100%', height: '100%' }}>
<div style={styles.previewWrapper}>
<HtmlPreview html={html} />
</div>
);
@@ -0,0 +1,19 @@
import { defineFrontComponent } from 'twenty-sdk/define';
import { RecordHtmlViewer } from '@modules/resend/html-viewer/components/RecordHtmlViewer';
import { BROADCAST_HTML_VIEWER_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER } from '@modules/resend/constants/universal-identifiers';
const BroadcastHtmlViewer = () => (
<RecordHtmlViewer
objectName="resendBroadcast"
loadingText="Loading broadcast..."
/>
);
export default defineFrontComponent({
universalIdentifier:
BROADCAST_HTML_VIEWER_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER,
name: 'Broadcast HTML Viewer',
description: 'Renders the HTML body of a Resend broadcast',
component: BroadcastHtmlViewer,
});
@@ -0,0 +1,92 @@
import { isDefined } from '@utils/is-defined';
import { defineFrontComponent } from 'twenty-sdk/define';
import {
Callout,
IconAlertCircle,
IconInfoCircle,
themeCssVariables,
} from 'twenty-sdk/ui';
import { EMAIL_BROADCAST_HTML_VIEWER_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER } from '@modules/resend/constants/universal-identifiers';
import { HtmlPreview } from '@modules/resend/html-viewer/components/HtmlPreview';
import { useRelatedBroadcastHtml } from '@modules/resend/html-viewer/hooks/useRelatedBroadcastHtml';
const getStyles = (): Record<string, React.CSSProperties> => {
const stateContainer: React.CSSProperties = {
padding: themeCssVariables.spacing[4],
fontFamily: themeCssVariables.font.family,
height: '100%',
boxSizing: 'border-box',
};
return {
stateContainer,
loader: {
...stateContainer,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: themeCssVariables.font.color.tertiary,
fontSize: themeCssVariables.font.size.sm,
},
previewWrapper: {
width: '100%',
height: '100%',
background: themeCssVariables.background.secondary,
border: `1px solid ${themeCssVariables.border.color.light}`,
borderRadius: themeCssVariables.border.radius.md,
overflow: 'hidden',
boxSizing: 'border-box',
},
};
};
const EmailBroadcastHtmlViewer = () => {
const { html, loading, error, hasBroadcast } = useRelatedBroadcastHtml();
const styles = getStyles();
if (loading) {
return <div style={styles.loader}>Loading broadcast preview...</div>;
}
if (isDefined(error)) {
return (
<div style={styles.stateContainer}>
<Callout
variant="error"
title="Failed to load broadcast preview"
description={error}
Icon={IconAlertCircle}
/>
</div>
);
}
if (!hasBroadcast) {
return (
<div style={styles.stateContainer}>
<Callout
variant="info"
title="No broadcast linked"
description="This email is not linked to a broadcast, so there is no HTML preview to display."
Icon={IconInfoCircle}
/>
</div>
);
}
return (
<div style={styles.previewWrapper}>
<HtmlPreview html={html} />
</div>
);
};
export default defineFrontComponent({
universalIdentifier:
EMAIL_BROADCAST_HTML_VIEWER_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER,
name: 'Email Broadcast HTML Viewer',
description:
'Renders the HTML body of the broadcast linked to a Resend email',
component: EmailBroadcastHtmlViewer,
});
@@ -1,15 +0,0 @@
import { defineFrontComponent } from 'twenty-sdk/define';
import { RecordHtmlViewer } from 'src/modules/resend/html-viewer/components/RecordHtmlViewer';
import { EMAIL_HTML_VIEWER_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER } from 'src/modules/resend/constants/universal-identifiers';
const EmailHtmlViewer = () => (
<RecordHtmlViewer objectName="resendEmail" loadingText="Loading email..." />
);
export default defineFrontComponent({
universalIdentifier: EMAIL_HTML_VIEWER_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER,
name: 'Email HTML Viewer',
description: 'Renders the HTML body of a Resend email',
component: EmailHtmlViewer,
});
@@ -1,7 +1,7 @@
import { defineFrontComponent } from 'twenty-sdk/define';
import { RecordHtmlViewer } from 'src/modules/resend/html-viewer/components/RecordHtmlViewer';
import { TEMPLATE_HTML_VIEWER_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER } from 'src/modules/resend/constants/universal-identifiers';
import { RecordHtmlViewer } from '@modules/resend/html-viewer/components/RecordHtmlViewer';
import { TEMPLATE_HTML_VIEWER_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER } from '@modules/resend/constants/universal-identifiers';
const TemplateHtmlViewer = () => (
<RecordHtmlViewer
@@ -1,7 +1,8 @@
import { useEffect, useState } from 'react';
import { useRecordId } from 'twenty-sdk/front-component';
import { CoreApiClient } from 'twenty-client-sdk/core';
import { isDefined } from 'twenty-shared/utils';
import { isDefined } from '@utils/is-defined';
type RecordHtmlState = {
html: string | null;
@@ -32,7 +33,7 @@ export const useRecordHtml = (objectName: string): RecordHtmlState => {
htmlBody: true,
},
})
.then((result) => {
.then((result: unknown) => {
const record = (result as Record<string, unknown>)[objectName] as
| { htmlBody?: string | null }
| undefined;
@@ -0,0 +1,96 @@
import { useEffect, useState } from 'react';
import { useRecordId } from 'twenty-sdk/front-component';
import { CoreApiClient } from 'twenty-client-sdk/core';
import { isDefined } from '@utils/is-defined';
type RelatedBroadcastHtmlState = {
html: string | null;
loading: boolean;
error: string | null;
hasBroadcast: boolean;
};
type ResendEmailWithBroadcast = {
broadcast?: { htmlBody?: string | null } | null;
};
export const useRelatedBroadcastHtml = (): RelatedBroadcastHtmlState => {
const recordId = useRecordId();
const [state, setState] = useState<RelatedBroadcastHtmlState>({
html: null,
loading: true,
error: null,
hasBroadcast: false,
});
useEffect(() => {
if (!isDefined(recordId)) {
setState({
html: null,
loading: false,
error: 'No record ID',
hasBroadcast: false,
});
return;
}
setState({ html: null, loading: true, error: null, hasBroadcast: false });
new CoreApiClient()
.query({
resendEmail: {
__args: { filter: { id: { eq: recordId } } },
broadcast: {
htmlBody: true,
},
},
})
.then((result: unknown) => {
const record = (result as Record<string, unknown>).resendEmail as
| ResendEmailWithBroadcast
| null
| undefined;
if (!isDefined(record)) {
setState({
html: null,
loading: false,
error: 'Email not found',
hasBroadcast: false,
});
return;
}
if (!isDefined(record.broadcast)) {
setState({
html: null,
loading: false,
error: null,
hasBroadcast: false,
});
return;
}
setState({
html: record.broadcast.htmlBody ?? null,
loading: false,
error: null,
hasBroadcast: true,
});
})
.catch((fetchError: unknown) => {
setState({
html: null,
loading: false,
error:
fetchError instanceof Error
? fetchError.message
: String(fetchError),
hasBroadcast: false,
});
});
}, [recordId]);
return state;
};
@@ -0,0 +1,259 @@
import { isDefined } from '@utils/is-defined';
import { useEffect, useState } from 'react';
import { CoreApiClient } from 'twenty-client-sdk/core';
import {
Callout,
H2Title,
IconAlertCircle,
IconRefresh,
Status,
themeCssVariables,
} from 'twenty-sdk/ui';
import { extractConnection } from '@modules/resend/shared/utils/typed-client';
import { RESEND_SYNC_CURSOR_STEPS } from '@modules/resend/sync/cursor/constants/resend-sync-cursor-steps';
import type { SyncCursorStep } from '@modules/resend/sync/cursor/types/sync-cursor-step';
type CursorRowStatus = 'SUCCESS' | 'FAILED' | 'IN_PROGRESS';
type CursorRow = {
id: string;
step: SyncCursorStep;
cursor: string | null;
lastRunAt: string | null;
lastRunStatus: CursorRowStatus | null;
};
type FetchState = {
rows: CursorRow[];
loading: boolean;
error: string | null;
};
type StatusThemeColor = 'green' | 'red' | 'orange' | 'gray';
const STATUS_COLOR_BY_RUN_STATUS: Record<CursorRowStatus, StatusThemeColor> = {
SUCCESS: 'green',
FAILED: 'red',
IN_PROGRESS: 'orange',
};
const STATUS_LABEL_BY_RUN_STATUS: Record<CursorRowStatus, string> = {
SUCCESS: 'Success',
FAILED: 'Failed',
IN_PROGRESS: 'In progress',
};
const formatTimestamp = (value: string | null): string => {
if (!isDefined(value)) {
return '—';
}
const parsed = new Date(value);
if (Number.isNaN(parsed.getTime())) {
return value;
}
return parsed.toLocaleString();
};
const formatStepLabel = (step: SyncCursorStep): string =>
step
.toLowerCase()
.split('_')
.map((part) =>
part.length > 0 ? part[0].toUpperCase() + part.slice(1) : part,
)
.join(' ');
const getStyles = (): Record<string, React.CSSProperties> => ({
container: {
fontFamily: themeCssVariables.font.family,
fontSize: themeCssVariables.font.size.sm,
color: themeCssVariables.font.color.primary,
display: 'flex',
flexDirection: 'column',
gap: themeCssVariables.spacing[3],
},
card: {
padding: themeCssVariables.spacing[3],
borderRadius: themeCssVariables.border.radius.md,
background: themeCssVariables.background.secondary,
border: `1px solid ${themeCssVariables.border.color.light}`,
display: 'flex',
flexDirection: 'column',
gap: themeCssVariables.spacing[2],
userSelect: 'text',
WebkitUserSelect: 'text',
cursor: 'text',
},
cardHeader: {
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
gap: themeCssVariables.spacing[2],
},
cursorCode: {
display: 'inline-block',
maxWidth: '100%',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
verticalAlign: 'bottom',
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, monospace',
fontSize: themeCssVariables.font.size.xs,
color: themeCssVariables.font.color.secondary,
background: themeCssVariables.background.transparent.light,
borderRadius: themeCssVariables.border.radius.sm,
padding: `0 ${themeCssVariables.spacing[1]}`,
},
h2TitleNoMargin: {
display: 'flex',
marginBottom: `calc(-1 * ${themeCssVariables.spacing[4]})`,
},
cardLine: {
fontSize: themeCssVariables.font.size.sm,
color: themeCssVariables.font.color.secondary,
display: 'flex',
alignItems: 'center',
gap: themeCssVariables.spacing[1],
},
});
export const ResendSyncStatus = () => {
const [state, setState] = useState<FetchState>({
rows: [],
loading: true,
error: null,
});
useEffect(() => {
let cancelled = false;
const load = async () => {
try {
const client = new CoreApiClient();
const cursorResult = await client.query({
resendSyncCursors: {
__args: { first: 50 },
edges: {
node: {
id: true,
step: true,
cursor: true,
lastRunAt: true,
lastRunStatus: true,
},
},
},
});
const connection = extractConnection<CursorRow>(
cursorResult,
'resendSyncCursors',
);
if (!cancelled) {
setState({
rows: connection.edges.map((edge) => edge.node),
loading: false,
error: null,
});
}
} catch (fetchError) {
if (!cancelled) {
setState({
rows: [],
loading: false,
error:
fetchError instanceof Error
? fetchError.message
: String(fetchError),
});
}
}
};
load();
return () => {
cancelled = true;
};
}, []);
const styles = getStyles();
if (state.loading) {
return (
<div style={styles.container}>
<Callout
variant="neutral"
title="Loading sync status"
description="Fetching cursors and queue counts…"
Icon={IconRefresh}
/>
</div>
);
}
if (isDefined(state.error)) {
return (
<div style={styles.container}>
<Callout
variant="error"
title="Failed to load sync status"
description={state.error}
Icon={IconAlertCircle}
/>
</div>
);
}
const rowByStep = new Map<SyncCursorStep, CursorRow>();
for (const row of state.rows) {
rowByStep.set(row.step, row);
}
return (
<div style={styles.container}>
{RESEND_SYNC_CURSOR_STEPS.map((step) => {
const row = rowByStep.get(step);
const runStatus = row?.lastRunStatus ?? null;
const cursor = row?.cursor ?? null;
const lastRunAt = row?.lastRunAt ?? null;
const statusColor: StatusThemeColor = isDefined(runStatus)
? STATUS_COLOR_BY_RUN_STATUS[runStatus]
: 'gray';
const statusLabel = isDefined(runStatus)
? STATUS_LABEL_BY_RUN_STATUS[runStatus]
: 'Not synced';
return (
<div key={step} style={styles.card}>
<div style={styles.cardHeader}>
<div style={styles.h2TitleNoMargin}>
<H2Title title={formatStepLabel(step)} />
</div>
<Status
color={statusColor}
text={statusLabel}
isLoaderVisible={runStatus === 'IN_PROGRESS'}
/>
</div>
<div style={styles.cardLine}>
Last run: {formatTimestamp(lastRunAt)}
</div>
{isDefined(cursor) && cursor !== '' && (
<div style={styles.cardLine}>
Resume cursor: <code style={styles.cursorCode}>{cursor}</code>
</div>
)}
</div>
);
})}
</div>
);
};
@@ -0,0 +1 @@
export const RESEND_SYNC_STATUS_NAVIGATION_MENU_ITEM_NAME = 'Sync Status';
@@ -0,0 +1,12 @@
import { defineFrontComponent } from 'twenty-sdk/define';
import { ResendSyncStatus } from '@modules/resend/manual-sync/components/ResendSyncStatus';
import { RESEND_SYNC_STATUS_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER } from '@modules/resend/constants/universal-identifiers';
export default defineFrontComponent({
universalIdentifier: RESEND_SYNC_STATUS_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER,
name: 'Resend sync status',
description:
'Displays the latest run timestamp, status, and error for each Resend sync step',
component: ResendSyncStatus,
});
@@ -1,74 +1,82 @@
import { isDefined } from '@utils/is-defined';
import { CoreApiClient } from 'twenty-client-sdk/core';
import { MetadataApiClient } from 'twenty-client-sdk/metadata';
import { defineFrontComponent } from 'twenty-sdk/define';
import { Command, enqueueSnackbar, updateProgress } from 'twenty-sdk/front-component';
import { isDefined } from 'twenty-shared/utils';
import {
AppPath,
Command,
enqueueSnackbar,
navigate,
} from 'twenty-sdk/front-component';
import { APPLICATION_UNIVERSAL_IDENTIFIER } from '@constants/universal-identifiers';
import { INITIAL_SYNC_MODE_ENV_VAR_NAME } from '@modules/resend/constants/sync-config';
import {
SYNC_RESEND_DATA_COMMAND_UNIVERSAL_IDENTIFIER,
SYNC_RESEND_DATA_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER,
SYNC_RESEND_DATA_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER,
} from 'src/modules/resend/constants/universal-identifiers';
} from '@modules/resend/constants/universal-identifiers';
import { resolveSyncStatusPageLayoutId } from '@modules/resend/manual-sync/utils/resolve-sync-status-page-layout-id';
import { resetAllSyncCursors } from '@modules/resend/sync/cursor/utils/reset-all-sync-cursors';
const execute = async () => {
await updateProgress(0.1);
const metadataClient = new MetadataApiClient();
const { findManyLogicFunctions } = await metadataClient.query({
findManyLogicFunctions: {
const resolveApplicationId = async (
metadataClient: MetadataApiClient,
): Promise<string> => {
const { findManyApplications } = await metadataClient.query({
findManyApplications: {
id: true,
universalIdentifier: true,
},
});
const syncFunction = findManyLogicFunctions.find(
(fn) =>
fn.universalIdentifier ===
SYNC_RESEND_DATA_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER,
const match = findManyApplications.find(
(application: { universalIdentifier: string }) =>
application.universalIdentifier === APPLICATION_UNIVERSAL_IDENTIFIER,
);
if (!isDefined(syncFunction)) {
throw new Error('Sync logic function not found');
if (!isDefined(match)) {
throw new Error('Twenty-for-Twenty application not found');
}
await updateProgress(0.3);
return match.id;
};
const { executeOneLogicFunction } = await metadataClient.mutation({
executeOneLogicFunction: {
const flipInitialSyncModeOn = async (
metadataClient: MetadataApiClient,
applicationId: string,
): Promise<void> => {
await metadataClient.mutation({
updateOneApplicationVariable: {
__args: {
input: {
id: syncFunction.id,
payload: {} as Record<string, never>,
},
key: INITIAL_SYNC_MODE_ENV_VAR_NAME,
value: 'true',
applicationId,
},
status: true,
error: true,
},
});
};
if (executeOneLogicFunction.status !== 'SUCCESS') {
const rawMessage =
typeof executeOneLogicFunction.error?.errorMessage === 'string'
? executeOneLogicFunction.error.errorMessage
: 'Sync logic function execution failed';
const execute = async () => {
const metadataClient = new MetadataApiClient();
const coreApiClient = new CoreApiClient();
const isRateLimit =
rawMessage.toLowerCase().includes('rate_limit') ||
rawMessage.toLowerCase().includes('rate limit');
await resetAllSyncCursors(coreApiClient);
throw new Error(
isRateLimit
? 'Sync failed: Resend API rate limit exceeded. Please try again later.'
: `Sync failed: ${rawMessage}`,
);
}
const applicationId = await resolveApplicationId(metadataClient);
await updateProgress(1);
await flipInitialSyncModeOn(metadataClient, applicationId);
const pageLayoutId = await resolveSyncStatusPageLayoutId(
metadataClient,
applicationId,
);
await enqueueSnackbar({
message: 'Resend data sync completed',
message:
'Sync cursors reset and initial sync triggered — it will run in the background.',
variant: 'success',
});
await navigate(AppPath.PageLayoutPage, { pageLayoutId });
};
const SyncResendData = () => <Command execute={execute} />;
@@ -76,7 +84,8 @@ const SyncResendData = () => <Command execute={execute} />;
export default defineFrontComponent({
universalIdentifier: SYNC_RESEND_DATA_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER,
name: 'Sync Resend Data',
description: 'Triggers a manual sync of all Resend data',
description:
'Resets every Resend sync cursor and flips the application into initial sync mode so the scheduled sync handlers restart from scratch on their next run.',
isHeadless: true,
component: SyncResendData,
command: {
@@ -0,0 +1,112 @@
import { MetadataApiClient } from 'twenty-client-sdk/metadata';
import { describe, expect, it, vi } from 'vitest';
import { RESEND_SYNC_STATUS_NAVIGATION_MENU_ITEM_NAME } from '@modules/resend/manual-sync/constants/resend-sync-status-menu-item-name';
import { resolveSyncStatusPageLayoutId } from '@modules/resend/manual-sync/utils/resolve-sync-status-page-layout-id';
type NavigationMenuItem = {
id: string;
applicationId: string | null;
type: string;
name: string | null;
pageLayoutId: string | null;
};
const makeClient = (navigationMenuItems: NavigationMenuItem[]) => {
const query = vi.fn(async () => ({ navigationMenuItems }));
return { query } as unknown as MetadataApiClient;
};
const APPLICATION_ID = 'app-1';
const PAGE_LAYOUT_ID = 'page-layout-1';
describe('resolveSyncStatusPageLayoutId', () => {
it('returns the pageLayoutId of the matching menu item', async () => {
const client = makeClient([
{
id: 'item-1',
applicationId: APPLICATION_ID,
type: 'PAGE_LAYOUT',
name: RESEND_SYNC_STATUS_NAVIGATION_MENU_ITEM_NAME,
pageLayoutId: PAGE_LAYOUT_ID,
},
]);
const result = await resolveSyncStatusPageLayoutId(client, APPLICATION_ID);
expect(result).toBe(PAGE_LAYOUT_ID);
});
it('ignores menu items from a different application', async () => {
const client = makeClient([
{
id: 'item-1',
applicationId: 'other-app',
type: 'PAGE_LAYOUT',
name: RESEND_SYNC_STATUS_NAVIGATION_MENU_ITEM_NAME,
pageLayoutId: 'other-page-layout',
},
]);
await expect(
resolveSyncStatusPageLayoutId(client, APPLICATION_ID),
).rejects.toThrow('Resend Sync Status page layout not found');
});
it('ignores menu items with a different type', async () => {
const client = makeClient([
{
id: 'item-1',
applicationId: APPLICATION_ID,
type: 'FOLDER',
name: RESEND_SYNC_STATUS_NAVIGATION_MENU_ITEM_NAME,
pageLayoutId: null,
},
]);
await expect(
resolveSyncStatusPageLayoutId(client, APPLICATION_ID),
).rejects.toThrow('Resend Sync Status page layout not found');
});
it('ignores menu items with a different name', async () => {
const client = makeClient([
{
id: 'item-1',
applicationId: APPLICATION_ID,
type: 'PAGE_LAYOUT',
name: 'Some Other Page',
pageLayoutId: PAGE_LAYOUT_ID,
},
]);
await expect(
resolveSyncStatusPageLayoutId(client, APPLICATION_ID),
).rejects.toThrow('Resend Sync Status page layout not found');
});
it('throws when the matching menu item has a null pageLayoutId', async () => {
const client = makeClient([
{
id: 'item-1',
applicationId: APPLICATION_ID,
type: 'PAGE_LAYOUT',
name: RESEND_SYNC_STATUS_NAVIGATION_MENU_ITEM_NAME,
pageLayoutId: null,
},
]);
await expect(
resolveSyncStatusPageLayoutId(client, APPLICATION_ID),
).rejects.toThrow('Resend Sync Status page layout not found');
});
it('throws when the response contains no menu items', async () => {
const client = makeClient([]);
await expect(
resolveSyncStatusPageLayoutId(client, APPLICATION_ID),
).rejects.toThrow('Resend Sync Status page layout not found');
});
});
@@ -0,0 +1,39 @@
import { isDefined } from '@utils/is-defined';
import { MetadataApiClient } from 'twenty-client-sdk/metadata';
import { RESEND_SYNC_STATUS_NAVIGATION_MENU_ITEM_NAME } from '@modules/resend/manual-sync/constants/resend-sync-status-menu-item-name';
type NavigationMenuItem = {
applicationId: string | null;
type: string;
name: string | null;
pageLayoutId: string | null;
};
export const resolveSyncStatusPageLayoutId = async (
metadataClient: MetadataApiClient,
applicationId: string,
): Promise<string> => {
const { navigationMenuItems } = await metadataClient.query({
navigationMenuItems: {
id: true,
name: true,
type: true,
applicationId: true,
pageLayoutId: true,
},
});
const match = (navigationMenuItems as NavigationMenuItem[]).find(
(item) =>
item.applicationId === applicationId &&
item.type === 'PAGE_LAYOUT' &&
item.name === RESEND_SYNC_STATUS_NAVIGATION_MENU_ITEM_NAME,
);
if (!isDefined(match) || !isDefined(match.pageLayoutId)) {
throw new Error('Resend Sync Status page layout not found');
}
return match.pageLayoutId;
};
@@ -3,7 +3,7 @@ import {
RESEND_BROADCAST_OBJECT_UNIVERSAL_IDENTIFIER,
RESEND_EMAILS_ON_BROADCAST_FIELD_UNIVERSAL_IDENTIFIER,
RESEND_EMAIL_OBJECT_UNIVERSAL_IDENTIFIER,
} from 'src/modules/resend/constants/universal-identifiers';
} from '@modules/resend/constants/universal-identifiers';
import { defineField, FieldType, RelationType } from 'twenty-sdk/define';
export default defineField({
@@ -3,7 +3,7 @@ import {
RESEND_CONTACT_OBJECT_UNIVERSAL_IDENTIFIER,
RESEND_EMAILS_ON_CONTACT_FIELD_UNIVERSAL_IDENTIFIER,
RESEND_EMAIL_OBJECT_UNIVERSAL_IDENTIFIER,
} from 'src/modules/resend/constants/universal-identifiers';
} from '@modules/resend/constants/universal-identifiers';
import { defineField, FieldType, RelationType } from 'twenty-sdk/define';
export default defineField({
@@ -20,5 +20,5 @@ export default defineField({
relationType: RelationType.MANY_TO_ONE,
joinColumnName: 'contactId',
},
icon: 'IconAddressBook',
icon: 'IconUser',
});
@@ -2,7 +2,7 @@ import {
PERSON_ON_RESEND_CONTACT_FIELD_UNIVERSAL_IDENTIFIER,
RESEND_CONTACTS_ON_PERSON_FIELD_UNIVERSAL_IDENTIFIER,
RESEND_CONTACT_OBJECT_UNIVERSAL_IDENTIFIER,
} from 'src/modules/resend/constants/universal-identifiers';
} from '@modules/resend/constants/universal-identifiers';
import { defineField, FieldType, RelationType, STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS } from 'twenty-sdk/define';
export default defineField({
@@ -2,7 +2,7 @@ import {
PERSON_ON_RESEND_EMAIL_FIELD_UNIVERSAL_IDENTIFIER,
RESEND_EMAILS_ON_PERSON_FIELD_UNIVERSAL_IDENTIFIER,
RESEND_EMAIL_OBJECT_UNIVERSAL_IDENTIFIER,
} from 'src/modules/resend/constants/universal-identifiers';
} from '@modules/resend/constants/universal-identifiers';
import { defineField, FieldType, RelationType, STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS } from 'twenty-sdk/define';
export default defineField({
@@ -3,7 +3,7 @@ import {
RESEND_BROADCAST_OBJECT_UNIVERSAL_IDENTIFIER,
RESEND_SEGMENT_OBJECT_UNIVERSAL_IDENTIFIER,
SEGMENT_ON_RESEND_BROADCAST_FIELD_UNIVERSAL_IDENTIFIER,
} from 'src/modules/resend/constants/universal-identifiers';
} from '@modules/resend/constants/universal-identifiers';
import { defineField, FieldType, RelationType } from 'twenty-sdk/define';
export default defineField({
@@ -0,0 +1,23 @@
import {
RESEND_BROADCASTS_ON_TOPIC_FIELD_UNIVERSAL_IDENTIFIER,
RESEND_BROADCAST_OBJECT_UNIVERSAL_IDENTIFIER,
RESEND_TOPIC_OBJECT_UNIVERSAL_IDENTIFIER,
TOPIC_ON_RESEND_BROADCAST_FIELD_UNIVERSAL_IDENTIFIER,
} from '@modules/resend/constants/universal-identifiers';
import { defineField, FieldType, RelationType } from 'twenty-sdk/define';
export default defineField({
universalIdentifier: RESEND_BROADCASTS_ON_TOPIC_FIELD_UNIVERSAL_IDENTIFIER,
objectUniversalIdentifier: RESEND_TOPIC_OBJECT_UNIVERSAL_IDENTIFIER,
type: FieldType.RELATION,
name: 'resendBroadcasts',
label: 'Broadcasts',
relationTargetObjectMetadataUniversalIdentifier:
RESEND_BROADCAST_OBJECT_UNIVERSAL_IDENTIFIER,
relationTargetFieldMetadataUniversalIdentifier:
TOPIC_ON_RESEND_BROADCAST_FIELD_UNIVERSAL_IDENTIFIER,
universalSettings: {
relationType: RelationType.ONE_TO_MANY,
},
icon: 'IconSpeakerphone',
});
@@ -2,7 +2,7 @@ import {
PERSON_ON_RESEND_CONTACT_FIELD_UNIVERSAL_IDENTIFIER,
RESEND_CONTACTS_ON_PERSON_FIELD_UNIVERSAL_IDENTIFIER,
RESEND_CONTACT_OBJECT_UNIVERSAL_IDENTIFIER,
} from 'src/modules/resend/constants/universal-identifiers';
} from '@modules/resend/constants/universal-identifiers';
import { defineField, FieldType, RelationType, STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS } from 'twenty-sdk/define';
export default defineField({
@@ -19,5 +19,5 @@ export default defineField({
universalSettings: {
relationType: RelationType.ONE_TO_MANY,
},
icon: 'IconAddressBook',
icon: 'IconUser',
});
@@ -3,7 +3,7 @@ import {
RESEND_CONTACT_OBJECT_UNIVERSAL_IDENTIFIER,
RESEND_SEGMENT_OBJECT_UNIVERSAL_IDENTIFIER,
SEGMENT_ON_RESEND_CONTACT_FIELD_UNIVERSAL_IDENTIFIER,
} from 'src/modules/resend/constants/universal-identifiers';
} from '@modules/resend/constants/universal-identifiers';
import { defineField, FieldType, RelationType } from 'twenty-sdk/define';
export default defineField({
@@ -19,5 +19,5 @@ export default defineField({
universalSettings: {
relationType: RelationType.ONE_TO_MANY,
},
icon: 'IconAddressBook',
icon: 'IconUser',
});
@@ -3,7 +3,7 @@ import {
RESEND_BROADCAST_OBJECT_UNIVERSAL_IDENTIFIER,
RESEND_EMAILS_ON_BROADCAST_FIELD_UNIVERSAL_IDENTIFIER,
RESEND_EMAIL_OBJECT_UNIVERSAL_IDENTIFIER,
} from 'src/modules/resend/constants/universal-identifiers';
} from '@modules/resend/constants/universal-identifiers';
import { defineField, FieldType, RelationType } from 'twenty-sdk/define';
export default defineField({
@@ -3,7 +3,7 @@ import {
RESEND_CONTACT_OBJECT_UNIVERSAL_IDENTIFIER,
RESEND_EMAILS_ON_CONTACT_FIELD_UNIVERSAL_IDENTIFIER,
RESEND_EMAIL_OBJECT_UNIVERSAL_IDENTIFIER,
} from 'src/modules/resend/constants/universal-identifiers';
} from '@modules/resend/constants/universal-identifiers';
import { defineField, FieldType, RelationType } from 'twenty-sdk/define';
export default defineField({
@@ -2,7 +2,7 @@ import {
PERSON_ON_RESEND_EMAIL_FIELD_UNIVERSAL_IDENTIFIER,
RESEND_EMAILS_ON_PERSON_FIELD_UNIVERSAL_IDENTIFIER,
RESEND_EMAIL_OBJECT_UNIVERSAL_IDENTIFIER,
} from 'src/modules/resend/constants/universal-identifiers';
} from '@modules/resend/constants/universal-identifiers';
import { defineField, FieldType, RelationType, STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS } from 'twenty-sdk/define';
export default defineField({
@@ -3,7 +3,7 @@ import {
RESEND_BROADCAST_OBJECT_UNIVERSAL_IDENTIFIER,
RESEND_SEGMENT_OBJECT_UNIVERSAL_IDENTIFIER,
SEGMENT_ON_RESEND_BROADCAST_FIELD_UNIVERSAL_IDENTIFIER,
} from 'src/modules/resend/constants/universal-identifiers';
} from '@modules/resend/constants/universal-identifiers';
import { defineField, FieldType, RelationType } from 'twenty-sdk/define';
export default defineField({
@@ -3,7 +3,7 @@ import {
RESEND_CONTACT_OBJECT_UNIVERSAL_IDENTIFIER,
RESEND_SEGMENT_OBJECT_UNIVERSAL_IDENTIFIER,
SEGMENT_ON_RESEND_CONTACT_FIELD_UNIVERSAL_IDENTIFIER,
} from 'src/modules/resend/constants/universal-identifiers';
} from '@modules/resend/constants/universal-identifiers';
import { defineField, FieldType, RelationType } from 'twenty-sdk/define';
export default defineField({
@@ -0,0 +1,24 @@
import {
RESEND_BROADCASTS_ON_TOPIC_FIELD_UNIVERSAL_IDENTIFIER,
RESEND_BROADCAST_OBJECT_UNIVERSAL_IDENTIFIER,
RESEND_TOPIC_OBJECT_UNIVERSAL_IDENTIFIER,
TOPIC_ON_RESEND_BROADCAST_FIELD_UNIVERSAL_IDENTIFIER,
} from '@modules/resend/constants/universal-identifiers';
import { defineField, FieldType, RelationType } from 'twenty-sdk/define';
export default defineField({
universalIdentifier: TOPIC_ON_RESEND_BROADCAST_FIELD_UNIVERSAL_IDENTIFIER,
objectUniversalIdentifier: RESEND_BROADCAST_OBJECT_UNIVERSAL_IDENTIFIER,
type: FieldType.RELATION,
name: 'topic',
label: 'Topic',
relationTargetObjectMetadataUniversalIdentifier:
RESEND_TOPIC_OBJECT_UNIVERSAL_IDENTIFIER,
relationTargetFieldMetadataUniversalIdentifier:
RESEND_BROADCASTS_ON_TOPIC_FIELD_UNIVERSAL_IDENTIFIER,
universalSettings: {
relationType: RelationType.MANY_TO_ONE,
joinColumnName: 'topicId',
},
icon: 'IconHash',
});
@@ -2,9 +2,9 @@ import {
RESEND_BROADCAST_NAVIGATION_MENU_ITEM_UNIVERSAL_IDENTIFIER,
RESEND_BROADCAST_VIEW_UNIVERSAL_IDENTIFIER,
RESEND_FOLDER_NAVIGATION_MENU_ITEM_UNIVERSAL_IDENTIFIER,
} from 'src/modules/resend/constants/universal-identifiers';
} from '@modules/resend/constants/universal-identifiers';
import { defineNavigationMenuItem } from 'twenty-sdk/define';
import { NavigationMenuItemType } from 'twenty-shared/types';
import { NavigationMenuItemType } from 'twenty-sdk/define';
export default defineNavigationMenuItem({
universalIdentifier:
@@ -2,14 +2,14 @@ import {
RESEND_CONTACT_NAVIGATION_MENU_ITEM_UNIVERSAL_IDENTIFIER,
RESEND_CONTACT_VIEW_UNIVERSAL_IDENTIFIER,
RESEND_FOLDER_NAVIGATION_MENU_ITEM_UNIVERSAL_IDENTIFIER,
} from 'src/modules/resend/constants/universal-identifiers';
} from '@modules/resend/constants/universal-identifiers';
import { defineNavigationMenuItem } from 'twenty-sdk/define';
import { NavigationMenuItemType } from 'twenty-shared/types';
import { NavigationMenuItemType } from 'twenty-sdk/define';
export default defineNavigationMenuItem({
universalIdentifier: RESEND_CONTACT_NAVIGATION_MENU_ITEM_UNIVERSAL_IDENTIFIER,
name: 'Contacts',
icon: 'IconAddressBook',
icon: 'IconUser',
position: 1,
type: NavigationMenuItemType.VIEW,
viewUniversalIdentifier: RESEND_CONTACT_VIEW_UNIVERSAL_IDENTIFIER,
@@ -2,9 +2,9 @@ import {
RESEND_EMAIL_NAVIGATION_MENU_ITEM_UNIVERSAL_IDENTIFIER,
RESEND_EMAIL_VIEW_UNIVERSAL_IDENTIFIER,
RESEND_FOLDER_NAVIGATION_MENU_ITEM_UNIVERSAL_IDENTIFIER,
} from 'src/modules/resend/constants/universal-identifiers';
} from '@modules/resend/constants/universal-identifiers';
import { defineNavigationMenuItem } from 'twenty-sdk/define';
import { NavigationMenuItemType } from 'twenty-shared/types';
import { NavigationMenuItemType } from 'twenty-sdk/define';
export default defineNavigationMenuItem({
universalIdentifier: RESEND_EMAIL_NAVIGATION_MENU_ITEM_UNIVERSAL_IDENTIFIER,
@@ -1,6 +1,6 @@
import { RESEND_FOLDER_NAVIGATION_MENU_ITEM_UNIVERSAL_IDENTIFIER } from 'src/modules/resend/constants/universal-identifiers';
import { RESEND_FOLDER_NAVIGATION_MENU_ITEM_UNIVERSAL_IDENTIFIER } from '@modules/resend/constants/universal-identifiers';
import { defineNavigationMenuItem } from 'twenty-sdk/define';
import { NavigationMenuItemType } from 'twenty-shared/types';
import { NavigationMenuItemType } from 'twenty-sdk/define';
export default defineNavigationMenuItem({
universalIdentifier: RESEND_FOLDER_NAVIGATION_MENU_ITEM_UNIVERSAL_IDENTIFIER,
@@ -2,9 +2,9 @@ import {
RESEND_FOLDER_NAVIGATION_MENU_ITEM_UNIVERSAL_IDENTIFIER,
RESEND_SEGMENT_NAVIGATION_MENU_ITEM_UNIVERSAL_IDENTIFIER,
RESEND_SEGMENT_VIEW_UNIVERSAL_IDENTIFIER,
} from 'src/modules/resend/constants/universal-identifiers';
} from '@modules/resend/constants/universal-identifiers';
import { defineNavigationMenuItem } from 'twenty-sdk/define';
import { NavigationMenuItemType } from 'twenty-shared/types';
import { NavigationMenuItemType } from 'twenty-sdk/define';
export default defineNavigationMenuItem({
universalIdentifier:
@@ -0,0 +1,21 @@
import {
RESEND_FOLDER_NAVIGATION_MENU_ITEM_UNIVERSAL_IDENTIFIER,
RESEND_SYNC_STATUS_NAVIGATION_MENU_ITEM_UNIVERSAL_IDENTIFIER,
RESEND_SYNC_STATUS_PAGE_LAYOUT_UNIVERSAL_IDENTIFIER,
} from '@modules/resend/constants/universal-identifiers';
import { RESEND_SYNC_STATUS_NAVIGATION_MENU_ITEM_NAME } from '@modules/resend/manual-sync/constants/resend-sync-status-menu-item-name';
import { defineNavigationMenuItem } from 'twenty-sdk/define';
import { NavigationMenuItemType } from 'twenty-sdk/define';
export default defineNavigationMenuItem({
universalIdentifier:
RESEND_SYNC_STATUS_NAVIGATION_MENU_ITEM_UNIVERSAL_IDENTIFIER,
name: RESEND_SYNC_STATUS_NAVIGATION_MENU_ITEM_NAME,
icon: 'IconRefresh',
position: 100,
type: NavigationMenuItemType.PAGE_LAYOUT,
pageLayoutUniversalIdentifier:
RESEND_SYNC_STATUS_PAGE_LAYOUT_UNIVERSAL_IDENTIFIER,
folderUniversalIdentifier:
RESEND_FOLDER_NAVIGATION_MENU_ITEM_UNIVERSAL_IDENTIFIER,
});
@@ -2,9 +2,9 @@ import {
RESEND_FOLDER_NAVIGATION_MENU_ITEM_UNIVERSAL_IDENTIFIER,
RESEND_TEMPLATE_NAVIGATION_MENU_ITEM_UNIVERSAL_IDENTIFIER,
RESEND_TEMPLATE_VIEW_UNIVERSAL_IDENTIFIER,
} from 'src/modules/resend/constants/universal-identifiers';
} from '@modules/resend/constants/universal-identifiers';
import { defineNavigationMenuItem } from 'twenty-sdk/define';
import { NavigationMenuItemType } from 'twenty-shared/types';
import { NavigationMenuItemType } from 'twenty-sdk/define';
export default defineNavigationMenuItem({
universalIdentifier:
@@ -0,0 +1,19 @@
import {
RESEND_FOLDER_NAVIGATION_MENU_ITEM_UNIVERSAL_IDENTIFIER,
RESEND_TOPIC_NAVIGATION_MENU_ITEM_UNIVERSAL_IDENTIFIER,
RESEND_TOPIC_VIEW_UNIVERSAL_IDENTIFIER,
} from '@modules/resend/constants/universal-identifiers';
import { defineNavigationMenuItem } from 'twenty-sdk/define';
import { NavigationMenuItemType } from 'twenty-sdk/define';
export default defineNavigationMenuItem({
universalIdentifier:
RESEND_TOPIC_NAVIGATION_MENU_ITEM_UNIVERSAL_IDENTIFIER,
name: 'Topics',
icon: 'IconHash',
position: 5,
type: NavigationMenuItemType.VIEW,
viewUniversalIdentifier: RESEND_TOPIC_VIEW_UNIVERSAL_IDENTIFIER,
folderUniversalIdentifier:
RESEND_FOLDER_NAVIGATION_MENU_ITEM_UNIVERSAL_IDENTIFIER,
});
@@ -1,6 +1,7 @@
import {
BROADCAST_CREATED_AT_FIELD_UNIVERSAL_IDENTIFIER,
BROADCAST_FROM_ADDRESS_FIELD_UNIVERSAL_IDENTIFIER,
BROADCAST_HTML_FIELD_UNIVERSAL_IDENTIFIER,
BROADCAST_NAME_FIELD_UNIVERSAL_IDENTIFIER,
BROADCAST_REPLY_TO_FIELD_UNIVERSAL_IDENTIFIER,
BROADCAST_RESEND_ID_FIELD_UNIVERSAL_IDENTIFIER,
@@ -8,9 +9,10 @@ import {
BROADCAST_SENT_AT_FIELD_UNIVERSAL_IDENTIFIER,
BROADCAST_STATUS_FIELD_UNIVERSAL_IDENTIFIER,
BROADCAST_SUBJECT_FIELD_UNIVERSAL_IDENTIFIER,
BROADCAST_TEXT_FIELD_UNIVERSAL_IDENTIFIER,
PREVIEW_TEXT_FIELD_UNIVERSAL_IDENTIFIER,
RESEND_BROADCAST_OBJECT_UNIVERSAL_IDENTIFIER,
} from 'src/modules/resend/constants/universal-identifiers';
} from '@modules/resend/constants/universal-identifiers';
import { defineObject, FieldType } from 'twenty-sdk/define';
export default defineObject({
@@ -64,6 +66,22 @@ export default defineObject({
description: 'Preview text shown in email clients',
icon: 'IconEye',
},
{
universalIdentifier: BROADCAST_HTML_FIELD_UNIVERSAL_IDENTIFIER,
type: FieldType.TEXT,
name: 'htmlBody',
label: 'HTML body',
description: 'HTML content of the broadcast',
icon: 'IconFileText',
},
{
universalIdentifier: BROADCAST_TEXT_FIELD_UNIVERSAL_IDENTIFIER,
type: FieldType.TEXT,
name: 'textBody',
label: 'Text body',
description: 'Plain text content of the broadcast',
icon: 'IconAlignLeft',
},
{
universalIdentifier: BROADCAST_STATUS_FIELD_UNIVERSAL_IDENTIFIER,
type: FieldType.SELECT,
@@ -6,7 +6,7 @@ import {
NAME_FIELD_UNIVERSAL_IDENTIFIER,
RESEND_CONTACT_OBJECT_UNIVERSAL_IDENTIFIER,
UNSUBSCRIBED_FIELD_UNIVERSAL_IDENTIFIER,
} from 'src/modules/resend/constants/universal-identifiers';
} from '@modules/resend/constants/universal-identifiers';
import { defineObject, FieldType } from 'twenty-sdk/define';
export default defineObject({
@@ -16,7 +16,7 @@ export default defineObject({
labelSingular: 'Resend contact',
labelPlural: 'Resend contacts',
description: 'A contact from Resend',
icon: 'IconAddressBook',
icon: 'IconUser',
labelIdentifierFieldMetadataUniversalIdentifier:
NAME_FIELD_UNIVERSAL_IDENTIFIER,
fields: [
@@ -5,16 +5,13 @@ import {
EMAIL_LAST_SYNCED_FROM_RESEND_FIELD_UNIVERSAL_IDENTIFIER,
EMAIL_RESEND_ID_FIELD_UNIVERSAL_IDENTIFIER,
FROM_ADDRESS_FIELD_UNIVERSAL_IDENTIFIER,
HTML_BODY_FIELD_UNIVERSAL_IDENTIFIER,
LAST_EVENT_FIELD_UNIVERSAL_IDENTIFIER,
REPLY_TO_ADDRESSES_FIELD_UNIVERSAL_IDENTIFIER,
RESEND_EMAIL_OBJECT_UNIVERSAL_IDENTIFIER,
SCHEDULED_AT_FIELD_UNIVERSAL_IDENTIFIER,
SUBJECT_FIELD_UNIVERSAL_IDENTIFIER,
TAGS_FIELD_UNIVERSAL_IDENTIFIER,
TEXT_BODY_FIELD_UNIVERSAL_IDENTIFIER,
TO_ADDRESSES_FIELD_UNIVERSAL_IDENTIFIER,
} from 'src/modules/resend/constants/universal-identifiers';
} from '@modules/resend/constants/universal-identifiers';
import { defineObject, FieldType } from 'twenty-sdk/define';
export default defineObject({
@@ -52,22 +49,6 @@ export default defineObject({
description: 'Recipient email addresses',
icon: 'IconUsers',
},
{
universalIdentifier: HTML_BODY_FIELD_UNIVERSAL_IDENTIFIER,
type: FieldType.TEXT,
name: 'htmlBody',
label: 'HTML body',
description: 'HTML content of the email',
icon: 'IconFileText',
},
{
universalIdentifier: TEXT_BODY_FIELD_UNIVERSAL_IDENTIFIER,
type: FieldType.TEXT,
name: 'textBody',
label: 'Text body',
description: 'Plain text content of the email',
icon: 'IconAlignLeft',
},
{
universalIdentifier: CC_ADDRESSES_FIELD_UNIVERSAL_IDENTIFIER,
type: FieldType.EMAILS,
@@ -217,14 +198,6 @@ export default defineObject({
description: 'When the email is scheduled to be sent',
icon: 'IconCalendarEvent',
},
{
universalIdentifier: TAGS_FIELD_UNIVERSAL_IDENTIFIER,
type: FieldType.RAW_JSON,
name: 'tags',
label: 'Tags',
description: 'Custom tags attached to the email',
icon: 'IconTag',
},
{
universalIdentifier:
EMAIL_LAST_SYNCED_FROM_RESEND_FIELD_UNIVERSAL_IDENTIFIER,
@@ -4,7 +4,7 @@ import {
SEGMENT_LAST_SYNCED_FROM_RESEND_FIELD_UNIVERSAL_IDENTIFIER,
SEGMENT_NAME_FIELD_UNIVERSAL_IDENTIFIER,
SEGMENT_RESEND_ID_FIELD_UNIVERSAL_IDENTIFIER,
} from 'src/modules/resend/constants/universal-identifiers';
} from '@modules/resend/constants/universal-identifiers';
import { defineObject, FieldType } from 'twenty-sdk/define';
export default defineObject({
@@ -0,0 +1,125 @@
import {
RESEND_SYNC_CURSOR_OBJECT_UNIVERSAL_IDENTIFIER,
SYNC_CURSOR_CURSOR_FIELD_UNIVERSAL_IDENTIFIER,
SYNC_CURSOR_LAST_RUN_AT_FIELD_UNIVERSAL_IDENTIFIER,
SYNC_CURSOR_LAST_RUN_STATUS_FIELD_UNIVERSAL_IDENTIFIER,
SYNC_CURSOR_STEP_FIELD_UNIVERSAL_IDENTIFIER,
} from 'src/modules/resend/constants/universal-identifiers';
import { defineObject, FieldType } from 'twenty-sdk/define';
export default defineObject({
universalIdentifier: RESEND_SYNC_CURSOR_OBJECT_UNIVERSAL_IDENTIFIER,
nameSingular: 'resendSyncCursor',
namePlural: 'resendSyncCursors',
labelSingular: 'Resend sync cursor',
labelPlural: 'Resend sync cursors',
description:
'Persisted per-step cursor state for the Resend sync (technical object used to resume failed runs)',
icon: 'IconBookmark',
labelIdentifierFieldMetadataUniversalIdentifier:
SYNC_CURSOR_CURSOR_FIELD_UNIVERSAL_IDENTIFIER,
fields: [
{
universalIdentifier: SYNC_CURSOR_STEP_FIELD_UNIVERSAL_IDENTIFIER,
type: FieldType.SELECT,
name: 'step',
label: 'Step',
description: 'Sync step this cursor tracks',
icon: 'IconHash',
isUnique: true,
options: [
{
id: 'c271f363-c680-446b-9318-06970b9af137',
value: 'SEGMENTS',
label: 'Segments',
position: 0,
color: 'gray',
},
{
id: '7b63e182-8124-4085-ad17-9f2f3c9988e5',
value: 'TEMPLATES',
label: 'Templates',
position: 1,
color: 'blue',
},
{
id: '0dd5ce53-94ec-4d1e-9ef5-5642d69922fe',
value: 'CONTACTS',
label: 'Contacts',
position: 2,
color: 'green',
},
{
id: 'c9b5dc16-74f4-46d1-bfcd-010e46ab62e6',
value: 'EMAILS',
label: 'Emails',
position: 3,
color: 'purple',
},
{
id: 'da6acca3-1341-42d3-a21c-aa18905d7536',
value: 'BROADCASTS',
label: 'Broadcasts',
position: 4,
color: 'orange',
},
{
id: 'abec7a2d-20c5-4304-9356-7cf05db67a71',
value: 'TOPICS',
label: 'Topics',
position: 5,
color: 'pink',
},
],
},
{
universalIdentifier: SYNC_CURSOR_CURSOR_FIELD_UNIVERSAL_IDENTIFIER,
type: FieldType.TEXT,
name: 'cursor',
label: 'Cursor',
description:
'Last successfully processed Resend ID; empty when not in progress',
icon: 'IconArrowBigRight',
},
{
universalIdentifier: SYNC_CURSOR_LAST_RUN_AT_FIELD_UNIVERSAL_IDENTIFIER,
type: FieldType.DATE_TIME,
name: 'lastRunAt',
label: 'Last run at',
description: 'Timestamp of the most recent sync run for this step',
icon: 'IconClock',
},
{
universalIdentifier:
SYNC_CURSOR_LAST_RUN_STATUS_FIELD_UNIVERSAL_IDENTIFIER,
type: FieldType.SELECT,
name: 'lastRunStatus',
label: 'Last run status',
description: 'Outcome of the most recent sync run for this step',
icon: 'IconActivity',
options: [
{
id: 'b8e6cc9c-3b1f-4a6c-9f2c-77c5e1f0c8b1',
value: 'SUCCESS',
label: 'Success',
position: 0,
color: 'green',
},
{
id: '6a8d7c44-cb5b-4d4d-b6d6-6e02e1f0aa12',
value: 'FAILED',
label: 'Failed',
position: 1,
color: 'red',
},
{
id: 'c8d2bea1-0c5f-4b78-95cf-2f1de4d2f0a3',
value: 'IN_PROGRESS',
label: 'In progress',
position: 2,
color: 'yellow',
},
],
},
],
});
@@ -12,7 +12,7 @@ import {
TEMPLATE_SUBJECT_FIELD_UNIVERSAL_IDENTIFIER,
TEMPLATE_TEXT_FIELD_UNIVERSAL_IDENTIFIER,
TEMPLATE_UPDATED_AT_FIELD_UNIVERSAL_IDENTIFIER,
} from 'src/modules/resend/constants/universal-identifiers';
} from '@modules/resend/constants/universal-identifiers';
import { defineObject, FieldType } from 'twenty-sdk/define';
export default defineObject({
@@ -0,0 +1,115 @@
import {
RESEND_TOPIC_OBJECT_UNIVERSAL_IDENTIFIER,
TOPIC_CREATED_AT_FIELD_UNIVERSAL_IDENTIFIER,
TOPIC_DEFAULT_SUBSCRIPTION_FIELD_UNIVERSAL_IDENTIFIER,
TOPIC_DESCRIPTION_FIELD_UNIVERSAL_IDENTIFIER,
TOPIC_LAST_SYNCED_FROM_RESEND_FIELD_UNIVERSAL_IDENTIFIER,
TOPIC_NAME_FIELD_UNIVERSAL_IDENTIFIER,
TOPIC_RESEND_ID_FIELD_UNIVERSAL_IDENTIFIER,
TOPIC_VISIBILITY_FIELD_UNIVERSAL_IDENTIFIER,
} from '@modules/resend/constants/universal-identifiers';
import { defineObject, FieldType } from 'twenty-sdk/define';
export default defineObject({
universalIdentifier: RESEND_TOPIC_OBJECT_UNIVERSAL_IDENTIFIER,
nameSingular: 'resendTopic',
namePlural: 'resendTopics',
labelSingular: 'Resend topic',
labelPlural: 'Resend topics',
description: 'A subscription topic from Resend',
icon: 'IconHash',
labelIdentifierFieldMetadataUniversalIdentifier:
TOPIC_NAME_FIELD_UNIVERSAL_IDENTIFIER,
fields: [
{
universalIdentifier: TOPIC_NAME_FIELD_UNIVERSAL_IDENTIFIER,
type: FieldType.TEXT,
name: 'name',
label: 'Name',
description: 'Name of the topic',
icon: 'IconAbc',
},
{
universalIdentifier: TOPIC_DESCRIPTION_FIELD_UNIVERSAL_IDENTIFIER,
type: FieldType.TEXT,
name: 'description',
label: 'Description',
description: 'Description of the topic',
icon: 'IconAlignLeft',
},
{
universalIdentifier: TOPIC_DEFAULT_SUBSCRIPTION_FIELD_UNIVERSAL_IDENTIFIER,
type: FieldType.SELECT,
name: 'defaultSubscription',
label: 'Default subscription',
description: 'Whether new contacts are subscribed to this topic by default',
icon: 'IconUserCheck',
options: [
{
id: '5c7f7114-6b96-49b2-88cc-d43cd87ee110',
value: 'OPT_IN',
label: 'Opt in',
position: 0,
color: 'green',
},
{
id: 'bff2b98d-7770-4b8f-be0e-ec0294e301df',
value: 'OPT_OUT',
label: 'Opt out',
position: 1,
color: 'gray',
},
],
},
{
universalIdentifier: TOPIC_VISIBILITY_FIELD_UNIVERSAL_IDENTIFIER,
type: FieldType.SELECT,
name: 'visibility',
label: 'Visibility',
description: 'Whether the topic is publicly visible to contacts',
icon: 'IconEye',
options: [
{
id: 'bdb9d1c3-0cb3-474e-8504-5bc2e1f66731',
value: 'PUBLIC',
label: 'Public',
position: 0,
color: 'green',
},
{
id: 'b7865f82-a387-48da-bb2d-5492d49ab6ad',
value: 'PRIVATE',
label: 'Private',
position: 1,
color: 'gray',
},
],
},
{
universalIdentifier: TOPIC_RESEND_ID_FIELD_UNIVERSAL_IDENTIFIER,
type: FieldType.TEXT,
name: 'resendId',
label: 'Resend ID',
description: 'Resend topic identifier',
icon: 'IconHash',
},
{
universalIdentifier: TOPIC_CREATED_AT_FIELD_UNIVERSAL_IDENTIFIER,
type: FieldType.DATE_TIME,
name: 'createdAt',
label: 'Created at',
description: 'When the topic was created',
icon: 'IconCalendar',
},
{
universalIdentifier:
TOPIC_LAST_SYNCED_FROM_RESEND_FIELD_UNIVERSAL_IDENTIFIER,
type: FieldType.DATE_TIME,
name: 'lastSyncedFromResend',
label: 'Last synced from Resend',
description:
'Timestamp of last inbound sync (used to prevent echo loops)',
icon: 'IconClock',
},
],
});
@@ -0,0 +1,143 @@
import {
BROADCAST_HTML_VIEWER_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER,
RESEND_BROADCAST_OBJECT_UNIVERSAL_IDENTIFIER,
RESEND_BROADCAST_RECORD_PAGE_FILES_TAB_UNIVERSAL_IDENTIFIER,
RESEND_BROADCAST_RECORD_PAGE_FILES_WIDGET_UNIVERSAL_IDENTIFIER,
RESEND_BROADCAST_RECORD_PAGE_HOME_FIELDS_WIDGET_UNIVERSAL_IDENTIFIER,
RESEND_BROADCAST_RECORD_PAGE_HOME_TAB_UNIVERSAL_IDENTIFIER,
RESEND_BROADCAST_RECORD_PAGE_LAYOUT_UNIVERSAL_IDENTIFIER,
RESEND_BROADCAST_RECORD_PAGE_NOTES_TAB_UNIVERSAL_IDENTIFIER,
RESEND_BROADCAST_RECORD_PAGE_NOTES_WIDGET_UNIVERSAL_IDENTIFIER,
RESEND_BROADCAST_RECORD_PAGE_PREVIEW_TAB_UNIVERSAL_IDENTIFIER,
RESEND_BROADCAST_RECORD_PAGE_PREVIEW_WIDGET_UNIVERSAL_IDENTIFIER,
RESEND_BROADCAST_RECORD_PAGE_TASKS_TAB_UNIVERSAL_IDENTIFIER,
RESEND_BROADCAST_RECORD_PAGE_TASKS_WIDGET_UNIVERSAL_IDENTIFIER,
RESEND_BROADCAST_RECORD_PAGE_TIMELINE_TAB_UNIVERSAL_IDENTIFIER,
RESEND_BROADCAST_RECORD_PAGE_TIMELINE_WIDGET_UNIVERSAL_IDENTIFIER,
} from '@modules/resend/constants/universal-identifiers';
import { definePageLayout, PageLayoutTabLayoutMode } from 'twenty-sdk/define';
export default definePageLayout({
universalIdentifier: RESEND_BROADCAST_RECORD_PAGE_LAYOUT_UNIVERSAL_IDENTIFIER,
name: 'Resend Broadcast Record Page',
type: 'RECORD_PAGE',
objectUniversalIdentifier: RESEND_BROADCAST_OBJECT_UNIVERSAL_IDENTIFIER,
tabs: [
{
universalIdentifier:
RESEND_BROADCAST_RECORD_PAGE_HOME_TAB_UNIVERSAL_IDENTIFIER,
title: 'Home',
position: 50,
icon: 'IconHome',
layoutMode: PageLayoutTabLayoutMode.VERTICAL_LIST,
widgets: [
{
universalIdentifier:
RESEND_BROADCAST_RECORD_PAGE_HOME_FIELDS_WIDGET_UNIVERSAL_IDENTIFIER,
title: 'Fields',
type: 'FIELDS',
configuration: {
configurationType: 'FIELDS',
},
},
],
},
{
universalIdentifier:
RESEND_BROADCAST_RECORD_PAGE_PREVIEW_TAB_UNIVERSAL_IDENTIFIER,
title: 'Preview',
position: 75,
icon: 'IconEye',
layoutMode: PageLayoutTabLayoutMode.CANVAS,
widgets: [
{
universalIdentifier:
RESEND_BROADCAST_RECORD_PAGE_PREVIEW_WIDGET_UNIVERSAL_IDENTIFIER,
title: 'Broadcast Preview',
type: 'FRONT_COMPONENT',
configuration: {
configurationType: 'FRONT_COMPONENT',
frontComponentUniversalIdentifier:
BROADCAST_HTML_VIEWER_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER,
},
},
],
},
{
universalIdentifier:
RESEND_BROADCAST_RECORD_PAGE_TIMELINE_TAB_UNIVERSAL_IDENTIFIER,
title: 'Timeline',
position: 100,
icon: 'IconTimelineEvent',
layoutMode: PageLayoutTabLayoutMode.CANVAS,
widgets: [
{
universalIdentifier:
RESEND_BROADCAST_RECORD_PAGE_TIMELINE_WIDGET_UNIVERSAL_IDENTIFIER,
title: 'Timeline',
type: 'TIMELINE',
configuration: {
configurationType: 'TIMELINE',
},
},
],
},
{
universalIdentifier:
RESEND_BROADCAST_RECORD_PAGE_TASKS_TAB_UNIVERSAL_IDENTIFIER,
title: 'Tasks',
position: 200,
icon: 'IconCheckbox',
layoutMode: PageLayoutTabLayoutMode.CANVAS,
widgets: [
{
universalIdentifier:
RESEND_BROADCAST_RECORD_PAGE_TASKS_WIDGET_UNIVERSAL_IDENTIFIER,
title: 'Tasks',
type: 'TASKS',
configuration: {
configurationType: 'TASKS',
},
},
],
},
{
universalIdentifier:
RESEND_BROADCAST_RECORD_PAGE_NOTES_TAB_UNIVERSAL_IDENTIFIER,
title: 'Notes',
position: 300,
icon: 'IconNotes',
layoutMode: PageLayoutTabLayoutMode.CANVAS,
widgets: [
{
universalIdentifier:
RESEND_BROADCAST_RECORD_PAGE_NOTES_WIDGET_UNIVERSAL_IDENTIFIER,
title: 'Notes',
type: 'NOTES',
configuration: {
configurationType: 'NOTES',
},
},
],
},
{
universalIdentifier:
RESEND_BROADCAST_RECORD_PAGE_FILES_TAB_UNIVERSAL_IDENTIFIER,
title: 'Files',
position: 400,
icon: 'IconPaperclip',
layoutMode: PageLayoutTabLayoutMode.CANVAS,
widgets: [
{
universalIdentifier:
RESEND_BROADCAST_RECORD_PAGE_FILES_WIDGET_UNIVERSAL_IDENTIFIER,
title: 'Files',
type: 'FILES',
configuration: {
configurationType: 'FILES',
},
},
],
},
],
});
@@ -1,5 +1,5 @@
import {
EMAIL_HTML_VIEWER_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER,
EMAIL_BROADCAST_HTML_VIEWER_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER,
RESEND_EMAIL_OBJECT_UNIVERSAL_IDENTIFIER,
RESEND_EMAIL_RECORD_PAGE_FILES_TAB_UNIVERSAL_IDENTIFIER,
RESEND_EMAIL_RECORD_PAGE_FILES_WIDGET_UNIVERSAL_IDENTIFIER,
@@ -14,7 +14,7 @@ import {
RESEND_EMAIL_RECORD_PAGE_TASKS_WIDGET_UNIVERSAL_IDENTIFIER,
RESEND_EMAIL_RECORD_PAGE_TIMELINE_TAB_UNIVERSAL_IDENTIFIER,
RESEND_EMAIL_RECORD_PAGE_TIMELINE_WIDGET_UNIVERSAL_IDENTIFIER,
} from 'src/modules/resend/constants/universal-identifiers';
} from '@modules/resend/constants/universal-identifiers';
import { definePageLayout, PageLayoutTabLayoutMode } from 'twenty-sdk/define';
export default definePageLayout({
@@ -52,12 +52,12 @@ export default definePageLayout({
{
universalIdentifier:
RESEND_EMAIL_RECORD_PAGE_PREVIEW_WIDGET_UNIVERSAL_IDENTIFIER,
title: 'Email Preview',
title: 'Broadcast Preview',
type: 'FRONT_COMPONENT',
configuration: {
configurationType: 'FRONT_COMPONENT',
frontComponentUniversalIdentifier:
EMAIL_HTML_VIEWER_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER,
EMAIL_BROADCAST_HTML_VIEWER_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER,
},
},
],
@@ -0,0 +1,42 @@
import {
RESEND_SYNC_STATUS_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER,
RESEND_SYNC_STATUS_PAGE_HOME_TAB_UNIVERSAL_IDENTIFIER,
RESEND_SYNC_STATUS_PAGE_LAYOUT_UNIVERSAL_IDENTIFIER,
RESEND_SYNC_STATUS_PAGE_WIDGET_UNIVERSAL_IDENTIFIER,
} from '@modules/resend/constants/universal-identifiers';
import { definePageLayout, PageLayoutTabLayoutMode } from 'twenty-sdk/define';
export default definePageLayout({
universalIdentifier: RESEND_SYNC_STATUS_PAGE_LAYOUT_UNIVERSAL_IDENTIFIER,
name: 'Resend Sync Status',
type: 'STANDALONE_PAGE',
tabs: [
{
universalIdentifier:
RESEND_SYNC_STATUS_PAGE_HOME_TAB_UNIVERSAL_IDENTIFIER,
title: 'Sync Status',
position: 0,
icon: 'IconRefresh',
layoutMode: PageLayoutTabLayoutMode.CANVAS,
widgets: [
{
universalIdentifier:
RESEND_SYNC_STATUS_PAGE_WIDGET_UNIVERSAL_IDENTIFIER,
title: 'Sync Status',
type: 'FRONT_COMPONENT',
configuration: {
configurationType: 'FRONT_COMPONENT',
frontComponentUniversalIdentifier:
RESEND_SYNC_STATUS_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER,
},
gridPosition: {
row: 0,
column: 0,
rowSpan: 12,
columnSpan: 12,
},
},
],
},
],
});
@@ -14,7 +14,7 @@ import {
RESEND_TEMPLATE_RECORD_PAGE_TIMELINE_TAB_UNIVERSAL_IDENTIFIER,
RESEND_TEMPLATE_RECORD_PAGE_TIMELINE_WIDGET_UNIVERSAL_IDENTIFIER,
TEMPLATE_HTML_VIEWER_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER,
} from 'src/modules/resend/constants/universal-identifiers';
} from '@modules/resend/constants/universal-identifiers';
import { definePageLayout, PageLayoutTabLayoutMode } from 'twenty-sdk/define';
export default definePageLayout({
@@ -14,10 +14,12 @@ import {
RESEND_BROADCAST_VIEW_SENT_AT_FIELD_UNIVERSAL_IDENTIFIER,
RESEND_BROADCAST_VIEW_STATUS_FIELD_UNIVERSAL_IDENTIFIER,
RESEND_BROADCAST_VIEW_SUBJECT_FIELD_UNIVERSAL_IDENTIFIER,
RESEND_BROADCAST_VIEW_TOPIC_FIELD_UNIVERSAL_IDENTIFIER,
RESEND_BROADCAST_VIEW_UNIVERSAL_IDENTIFIER,
RESEND_EMAILS_ON_BROADCAST_FIELD_UNIVERSAL_IDENTIFIER,
SEGMENT_ON_RESEND_BROADCAST_FIELD_UNIVERSAL_IDENTIFIER,
} from 'src/modules/resend/constants/universal-identifiers';
TOPIC_ON_RESEND_BROADCAST_FIELD_UNIVERSAL_IDENTIFIER,
} from '@modules/resend/constants/universal-identifiers';
import { defineView } from 'twenty-sdk/define';
export default defineView({
@@ -89,6 +91,15 @@ export default defineView({
size: 12,
position: 6,
},
{
universalIdentifier:
RESEND_BROADCAST_VIEW_TOPIC_FIELD_UNIVERSAL_IDENTIFIER,
fieldMetadataUniversalIdentifier:
TOPIC_ON_RESEND_BROADCAST_FIELD_UNIVERSAL_IDENTIFIER,
isVisible: true,
size: 12,
position: 7,
},
{
universalIdentifier:
RESEND_BROADCAST_VIEW_EMAILS_FIELD_UNIVERSAL_IDENTIFIER,
@@ -96,7 +107,7 @@ export default defineView({
RESEND_EMAILS_ON_BROADCAST_FIELD_UNIVERSAL_IDENTIFIER,
isVisible: true,
size: 12,
position: 7,
position: 8,
},
],
});
@@ -15,14 +15,14 @@ import {
RESEND_EMAILS_ON_CONTACT_FIELD_UNIVERSAL_IDENTIFIER,
SEGMENT_ON_RESEND_CONTACT_FIELD_UNIVERSAL_IDENTIFIER,
UNSUBSCRIBED_FIELD_UNIVERSAL_IDENTIFIER,
} from 'src/modules/resend/constants/universal-identifiers';
} from '@modules/resend/constants/universal-identifiers';
import { defineView } from 'twenty-sdk/define';
export default defineView({
universalIdentifier: RESEND_CONTACT_VIEW_UNIVERSAL_IDENTIFIER,
name: 'Resend contacts',
objectUniversalIdentifier: RESEND_CONTACT_OBJECT_UNIVERSAL_IDENTIFIER,
icon: 'IconAddressBook',
icon: 'IconUser',
position: 0,
fields: [
{
@@ -13,9 +13,11 @@ import {
RESEND_EMAIL_VIEW_LAST_EVENT_FIELD_UNIVERSAL_IDENTIFIER,
RESEND_EMAIL_VIEW_PERSON_FIELD_UNIVERSAL_IDENTIFIER,
RESEND_EMAIL_VIEW_SUBJECT_FIELD_UNIVERSAL_IDENTIFIER,
RESEND_EMAIL_VIEW_TO_ADDRESSES_FIELD_UNIVERSAL_IDENTIFIER,
RESEND_EMAIL_VIEW_UNIVERSAL_IDENTIFIER,
SUBJECT_FIELD_UNIVERSAL_IDENTIFIER,
} from 'src/modules/resend/constants/universal-identifiers';
TO_ADDRESSES_FIELD_UNIVERSAL_IDENTIFIER,
} from '@modules/resend/constants/universal-identifiers';
import { defineView } from 'twenty-sdk/define';
export default defineView({
@@ -35,31 +37,13 @@ export default defineView({
},
{
universalIdentifier:
RESEND_EMAIL_VIEW_FROM_ADDRESS_FIELD_UNIVERSAL_IDENTIFIER,
RESEND_EMAIL_VIEW_TO_ADDRESSES_FIELD_UNIVERSAL_IDENTIFIER,
fieldMetadataUniversalIdentifier:
FROM_ADDRESS_FIELD_UNIVERSAL_IDENTIFIER,
TO_ADDRESSES_FIELD_UNIVERSAL_IDENTIFIER,
isVisible: true,
size: 12,
position: 1,
},
{
universalIdentifier:
RESEND_EMAIL_VIEW_LAST_EVENT_FIELD_UNIVERSAL_IDENTIFIER,
fieldMetadataUniversalIdentifier:
LAST_EVENT_FIELD_UNIVERSAL_IDENTIFIER,
isVisible: true,
size: 12,
position: 2,
},
{
universalIdentifier:
RESEND_EMAIL_VIEW_CREATED_AT_FIELD_UNIVERSAL_IDENTIFIER,
fieldMetadataUniversalIdentifier:
EMAIL_CREATED_AT_FIELD_UNIVERSAL_IDENTIFIER,
isVisible: true,
size: 12,
position: 3,
},
{
universalIdentifier:
RESEND_EMAIL_VIEW_PERSON_FIELD_UNIVERSAL_IDENTIFIER,
@@ -67,8 +51,35 @@ export default defineView({
PERSON_ON_RESEND_EMAIL_FIELD_UNIVERSAL_IDENTIFIER,
isVisible: true,
size: 12,
position: 2,
},
{
universalIdentifier:
RESEND_EMAIL_VIEW_LAST_EVENT_FIELD_UNIVERSAL_IDENTIFIER,
fieldMetadataUniversalIdentifier:
LAST_EVENT_FIELD_UNIVERSAL_IDENTIFIER,
isVisible: true,
size: 12,
position: 3,
},
{
universalIdentifier:
RESEND_EMAIL_VIEW_FROM_ADDRESS_FIELD_UNIVERSAL_IDENTIFIER,
fieldMetadataUniversalIdentifier:
FROM_ADDRESS_FIELD_UNIVERSAL_IDENTIFIER,
isVisible: true,
size: 12,
position: 4,
},
{
universalIdentifier:
RESEND_EMAIL_VIEW_CREATED_AT_FIELD_UNIVERSAL_IDENTIFIER,
fieldMetadataUniversalIdentifier:
EMAIL_CREATED_AT_FIELD_UNIVERSAL_IDENTIFIER,
isVisible: true,
size: 12,
position: 5,
},
{
universalIdentifier:
RESEND_EMAIL_VIEW_CONTACT_FIELD_UNIVERSAL_IDENTIFIER,
@@ -76,7 +87,7 @@ export default defineView({
CONTACT_ON_RESEND_EMAIL_FIELD_UNIVERSAL_IDENTIFIER,
isVisible: true,
size: 12,
position: 5,
position: 6,
},
{
universalIdentifier:
@@ -85,7 +96,7 @@ export default defineView({
BROADCAST_ON_RESEND_EMAIL_FIELD_UNIVERSAL_IDENTIFIER,
isVisible: true,
size: 12,
position: 6,
position: 7,
},
],
});
@@ -9,7 +9,7 @@ import {
RESEND_SEGMENT_VIEW_UNIVERSAL_IDENTIFIER,
SEGMENT_CREATED_AT_FIELD_UNIVERSAL_IDENTIFIER,
SEGMENT_NAME_FIELD_UNIVERSAL_IDENTIFIER,
} from 'src/modules/resend/constants/universal-identifiers';
} from '@modules/resend/constants/universal-identifiers';
import { defineView } from 'twenty-sdk/define';
export default defineView({
@@ -13,7 +13,7 @@ import {
TEMPLATE_STATUS_FIELD_UNIVERSAL_IDENTIFIER,
TEMPLATE_SUBJECT_FIELD_UNIVERSAL_IDENTIFIER,
TEMPLATE_UPDATED_AT_FIELD_UNIVERSAL_IDENTIFIER,
} from 'src/modules/resend/constants/universal-identifiers';
} from '@modules/resend/constants/universal-identifiers';
import { defineView } from 'twenty-sdk/define';
export default defineView({
@@ -0,0 +1,79 @@
import {
RESEND_BROADCASTS_ON_TOPIC_FIELD_UNIVERSAL_IDENTIFIER,
RESEND_TOPIC_OBJECT_UNIVERSAL_IDENTIFIER,
RESEND_TOPIC_VIEW_BROADCASTS_FIELD_UNIVERSAL_IDENTIFIER,
RESEND_TOPIC_VIEW_CREATED_AT_FIELD_UNIVERSAL_IDENTIFIER,
RESEND_TOPIC_VIEW_DEFAULT_SUBSCRIPTION_FIELD_UNIVERSAL_IDENTIFIER,
RESEND_TOPIC_VIEW_DESCRIPTION_FIELD_UNIVERSAL_IDENTIFIER,
RESEND_TOPIC_VIEW_NAME_FIELD_UNIVERSAL_IDENTIFIER,
RESEND_TOPIC_VIEW_UNIVERSAL_IDENTIFIER,
RESEND_TOPIC_VIEW_VISIBILITY_FIELD_UNIVERSAL_IDENTIFIER,
TOPIC_CREATED_AT_FIELD_UNIVERSAL_IDENTIFIER,
TOPIC_DEFAULT_SUBSCRIPTION_FIELD_UNIVERSAL_IDENTIFIER,
TOPIC_DESCRIPTION_FIELD_UNIVERSAL_IDENTIFIER,
TOPIC_NAME_FIELD_UNIVERSAL_IDENTIFIER,
TOPIC_VISIBILITY_FIELD_UNIVERSAL_IDENTIFIER,
} from '@modules/resend/constants/universal-identifiers';
import { defineView } from 'twenty-sdk/define';
export default defineView({
universalIdentifier: RESEND_TOPIC_VIEW_UNIVERSAL_IDENTIFIER,
name: 'Resend topics',
objectUniversalIdentifier: RESEND_TOPIC_OBJECT_UNIVERSAL_IDENTIFIER,
icon: 'IconHash',
position: 0,
fields: [
{
universalIdentifier: RESEND_TOPIC_VIEW_NAME_FIELD_UNIVERSAL_IDENTIFIER,
fieldMetadataUniversalIdentifier: TOPIC_NAME_FIELD_UNIVERSAL_IDENTIFIER,
isVisible: true,
size: 12,
position: 0,
},
{
universalIdentifier:
RESEND_TOPIC_VIEW_DESCRIPTION_FIELD_UNIVERSAL_IDENTIFIER,
fieldMetadataUniversalIdentifier:
TOPIC_DESCRIPTION_FIELD_UNIVERSAL_IDENTIFIER,
isVisible: true,
size: 12,
position: 1,
},
{
universalIdentifier:
RESEND_TOPIC_VIEW_VISIBILITY_FIELD_UNIVERSAL_IDENTIFIER,
fieldMetadataUniversalIdentifier:
TOPIC_VISIBILITY_FIELD_UNIVERSAL_IDENTIFIER,
isVisible: true,
size: 12,
position: 2,
},
{
universalIdentifier:
RESEND_TOPIC_VIEW_DEFAULT_SUBSCRIPTION_FIELD_UNIVERSAL_IDENTIFIER,
fieldMetadataUniversalIdentifier:
TOPIC_DEFAULT_SUBSCRIPTION_FIELD_UNIVERSAL_IDENTIFIER,
isVisible: true,
size: 12,
position: 3,
},
{
universalIdentifier:
RESEND_TOPIC_VIEW_CREATED_AT_FIELD_UNIVERSAL_IDENTIFIER,
fieldMetadataUniversalIdentifier:
TOPIC_CREATED_AT_FIELD_UNIVERSAL_IDENTIFIER,
isVisible: true,
size: 12,
position: 4,
},
{
universalIdentifier:
RESEND_TOPIC_VIEW_BROADCASTS_FIELD_UNIVERSAL_IDENTIFIER,
fieldMetadataUniversalIdentifier:
RESEND_BROADCASTS_ON_TOPIC_FIELD_UNIVERSAL_IDENTIFIER,
isVisible: true,
size: 12,
position: 5,
},
],
});
@@ -0,0 +1,91 @@
import { CoreApiClient } from 'twenty-client-sdk/core';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { findResendContactsByEmail } from '@modules/resend/shared/utils/find-resend-contacts-by-email';
describe('findResendContactsByEmail', () => {
const buildClient = (
queryImpl: ReturnType<typeof vi.fn>,
): CoreApiClient => ({ query: queryImpl }) as unknown as CoreApiClient;
beforeEach(() => {
vi.clearAllMocks();
});
it('returns an empty map without querying when no emails are provided', async () => {
const query = vi.fn();
const result = await findResendContactsByEmail(buildClient(query), []);
expect(result.size).toBe(0);
expect(query).not.toHaveBeenCalled();
});
it('returns an empty map without querying when only nullish/empty emails are provided', async () => {
const query = vi.fn();
const result = await findResendContactsByEmail(buildClient(query), [
null,
undefined,
'',
]);
expect(result.size).toBe(0);
expect(query).not.toHaveBeenCalled();
});
it('queries with deduplicated normalized emails and indexes results by normalized primaryEmail', async () => {
const query = vi.fn(async () => ({
resendContacts: {
edges: [
{
node: {
id: 'contact-1',
personId: 'person-1',
email: { primaryEmail: 'Foo@Example.com' },
},
},
{
node: {
id: 'contact-2',
personId: null,
email: { primaryEmail: 'bar@example.com' },
},
},
{
node: {
id: 'contact-3',
personId: null,
email: { primaryEmail: null },
},
},
],
},
}));
const result = await findResendContactsByEmail(buildClient(query), [
' Foo@Example.com ',
'foo@example.com',
'bar@example.com',
]);
expect(query).toHaveBeenCalledTimes(1);
const args = query.mock.calls[0][0];
expect(args.resendContacts.__args.filter.email.primaryEmail.in).toEqual([
'foo@example.com',
'bar@example.com',
]);
expect(result.get('foo@example.com')).toEqual({
id: 'contact-1',
personId: 'person-1',
});
expect(result.get('bar@example.com')).toEqual({
id: 'contact-2',
personId: null,
});
expect(result.size).toBe(2);
});
});
@@ -0,0 +1,262 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import {
forEachPage,
type ResendListFunction,
} from '@modules/resend/shared/utils/for-each-page';
type Item = { id: string };
type ListResponse = Awaited<ReturnType<ResendListFunction<Item>>>;
const page = (ids: string[], hasMore: boolean): ListResponse => ({
data: { data: ids.map((id) => ({ id })), has_more: hasMore },
error: null,
});
const createMockListFunction = (
pages: ListResponse[],
): {
listFunction: ResendListFunction<Item>;
calls: { limit: number; after?: string }[];
} => {
const calls: { limit: number; after?: string }[] = [];
let index = 0;
const listFunction: ResendListFunction<Item> = async (
paginationParameters,
) => {
calls.push(paginationParameters);
const result = pages[index] ?? page([], false);
index++;
return result;
};
return { listFunction, calls };
};
beforeEach(() => {
vi.spyOn(console, 'log').mockImplementation(() => {});
vi.spyOn(console, 'warn').mockImplementation(() => {});
});
afterEach(() => {
vi.restoreAllMocks();
});
describe('forEachPage', () => {
it('calls onPage with each page in order', async () => {
const { listFunction } = createMockListFunction([
page(['a', 'b'], true),
page(['c'], false),
]);
const seen: { ids: string[]; pageNumber: number }[] = [];
await forEachPage(listFunction, async (items, pageNumber) => {
seen.push({ ids: items.map((item) => item.id), pageNumber });
});
expect(seen).toEqual([
{ ids: ['a', 'b'], pageNumber: 1 },
{ ids: ['c'], pageNumber: 2 },
]);
});
it('starts from options.startCursor when provided', async () => {
const { listFunction, calls } = createMockListFunction([
page(['c', 'd'], false),
]);
await forEachPage(listFunction, async () => {}, 'items', {
startCursor: 'b',
});
expect(calls[0]).toEqual({ limit: 100, after: 'b' });
});
it('omits after when startCursor is an empty string', async () => {
const { listFunction, calls } = createMockListFunction([
page(['a'], false),
]);
await forEachPage(listFunction, async () => {}, 'items', {
startCursor: '',
});
expect(calls[0]).toEqual({ limit: 100 });
});
it('calls onCursorAdvance after each successful page with the last item id', async () => {
const { listFunction } = createMockListFunction([
page(['a', 'b'], true),
page(['c', 'd'], false),
]);
const advances: { cursor: string }[] = [];
await forEachPage(listFunction, async () => {}, 'items', {
onCursorAdvance: async (cursor) => {
advances.push({ cursor });
},
});
expect(advances).toEqual([{ cursor: 'b' }, { cursor: 'd' }]);
});
it('does not call onCursorAdvance when onPage throws', async () => {
const { listFunction } = createMockListFunction([
page(['a'], true),
page(['b'], false),
]);
const onCursorAdvance = vi.fn(async () => {});
await expect(
forEachPage(
listFunction,
async () => {
throw new Error('boom');
},
'items',
{ onCursorAdvance },
),
).rejects.toThrow('boom');
expect(onCursorAdvance).not.toHaveBeenCalled();
});
it('persists cursor for the final page even when has_more is false', async () => {
const { listFunction } = createMockListFunction([page(['a', 'b'], false)]);
const onCursorAdvance = vi.fn(async () => {});
await forEachPage(listFunction, async () => {}, 'items', {
onCursorAdvance,
});
expect(onCursorAdvance).toHaveBeenCalledTimes(1);
expect(onCursorAdvance).toHaveBeenCalledWith('b');
});
it('continues past pages that return ok=false and advances the cursor over them', async () => {
const { listFunction, calls } = createMockListFunction([
page(['a', 'b'], true),
page(['c', 'd'], true),
page(['e', 'f'], false),
]);
const onCursorAdvance = vi.fn(async () => {});
const seenPages: string[][] = [];
await forEachPage(
listFunction,
async (items, pageNumber) => {
seenPages.push(items.map((item) => item.id));
return { ok: pageNumber !== 2, errors: ['boom'] };
},
'items',
{ onCursorAdvance },
);
expect(seenPages).toEqual([
['a', 'b'],
['c', 'd'],
['e', 'f'],
]);
expect(calls).toHaveLength(3);
expect(onCursorAdvance).toHaveBeenCalledTimes(3);
expect(onCursorAdvance).toHaveBeenLastCalledWith('f');
});
it('treats a void onPage return as ok and advances the cursor', async () => {
const { listFunction } = createMockListFunction([page(['a'], false)]);
const onCursorAdvance = vi.fn(async () => {});
await forEachPage(listFunction, async () => {}, 'items', {
onCursorAdvance,
});
expect(onCursorAdvance).toHaveBeenCalledWith('a');
});
it('throws when the cursor does not advance across pages', async () => {
const { listFunction } = createMockListFunction([
page(['a'], true),
page(['a'], true),
]);
await expect(
forEachPage(listFunction, async () => {}, 'segments'),
).rejects.toThrow(/Resend list\[segments\] cursor stuck at a/);
});
it('stops requesting additional pages once deadlineAtMs is reached', async () => {
const { listFunction, calls } = createMockListFunction([
page(['a', 'b'], true),
page(['c', 'd'], true),
page(['e', 'f'], true),
]);
const onCursorAdvance = vi.fn(async () => {});
const seenPages: string[][] = [];
let now = 1_000;
const dateNowSpy = vi.spyOn(Date, 'now').mockImplementation(() => now);
await forEachPage(
listFunction,
async (items) => {
seenPages.push(items.map((item) => item.id));
now += 1_000;
},
'items',
{
onCursorAdvance,
deadlineAtMs: 2_500,
},
);
expect(seenPages).toEqual([
['a', 'b'],
['c', 'd'],
]);
expect(calls).toHaveLength(2);
expect(onCursorAdvance).toHaveBeenCalledTimes(2);
expect(onCursorAdvance).toHaveBeenLastCalledWith('d');
dateNowSpy.mockRestore();
});
it('still processes the first page even if the deadline is already past at start', async () => {
const { listFunction, calls } = createMockListFunction([
page(['a'], true),
page(['b'], false),
]);
const onCursorAdvance = vi.fn(async () => {});
const seenPages: string[][] = [];
const dateNowSpy = vi.spyOn(Date, 'now').mockReturnValue(10_000);
await forEachPage(
listFunction,
async (items) => {
seenPages.push(items.map((item) => item.id));
},
'items',
{
onCursorAdvance,
deadlineAtMs: 0,
},
);
expect(seenPages).toEqual([['a']]);
expect(calls).toHaveLength(1);
expect(onCursorAdvance).toHaveBeenCalledTimes(1);
expect(onCursorAdvance).toHaveBeenCalledWith('a');
dateNowSpy.mockRestore();
});
});
@@ -0,0 +1,94 @@
import { describe, expect, it } from 'vitest';
import { toEmailsField } from '@modules/resend/shared/utils/to-emails-field';
describe('toEmailsField', () => {
it('returns a plain email unchanged', () => {
expect(toEmailsField('thomas@mail.twenty.com')).toEqual({
primaryEmail: 'thomas@mail.twenty.com',
additionalEmails: null,
});
});
it('extracts the email from "Name <email>" format', () => {
expect(toEmailsField('Thomas <thomas@mail.twenty.com>')).toEqual({
primaryEmail: 'thomas@mail.twenty.com',
additionalEmails: null,
});
});
it('extracts the email from a quoted display name', () => {
expect(toEmailsField('"Last, First" <thomas@mail.twenty.com>')).toEqual({
primaryEmail: 'thomas@mail.twenty.com',
additionalEmails: null,
});
});
it('normalizes each entry of an array of mixed formats', () => {
expect(
toEmailsField([
'Alice <alice@example.com>',
'bob@example.com',
'"Carol C." <carol@example.com>',
]),
).toEqual({
primaryEmail: 'alice@example.com',
additionalEmails: ['bob@example.com', 'carol@example.com'],
});
});
it('returns empty primaryEmail for null', () => {
expect(toEmailsField(null)).toEqual({
primaryEmail: '',
additionalEmails: null,
});
});
it('returns empty primaryEmail for undefined', () => {
expect(toEmailsField(undefined)).toEqual({
primaryEmail: '',
additionalEmails: null,
});
});
it('returns empty primaryEmail for an empty string', () => {
expect(toEmailsField('')).toEqual({
primaryEmail: '',
additionalEmails: null,
});
});
it('trims surrounding whitespace from a plain email', () => {
expect(toEmailsField(' thomas@mail.twenty.com ')).toEqual({
primaryEmail: 'thomas@mail.twenty.com',
additionalEmails: null,
});
});
it('lowercases a plain email', () => {
expect(toEmailsField('Thomas@Mail.Twenty.Com')).toEqual({
primaryEmail: 'thomas@mail.twenty.com',
additionalEmails: null,
});
});
it('lowercases the address inside the "Name <email>" format', () => {
expect(toEmailsField('Thomas <Thomas@Mail.Twenty.Com>')).toEqual({
primaryEmail: 'thomas@mail.twenty.com',
additionalEmails: null,
});
});
it('lowercases each entry of an array', () => {
expect(
toEmailsField([
'Alice <Alice@Example.com>',
'BOB@example.com',
'"Carol C." <Carol@EXAMPLE.com>',
]),
).toEqual({
primaryEmail: 'alice@example.com',
additionalEmails: ['bob@example.com', 'carol@example.com'],
});
});
});
@@ -0,0 +1,76 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { withRateLimitRetry } from '@modules/resend/shared/utils/with-rate-limit-retry';
beforeEach(() => {
vi.spyOn(console, 'warn').mockImplementation(() => {});
});
afterEach(() => {
vi.restoreAllMocks();
});
describe('withRateLimitRetry', () => {
it('returns the resolved value when no error is present', async () => {
const result = await withRateLimitRetry(async () => ({
data: { ok: true },
error: null,
}));
expect(result).toEqual({ data: { ok: true }, error: null });
});
it('retries on a Resend-shape rate-limit error response and eventually returns success', async () => {
const fn = vi
.fn()
.mockResolvedValueOnce({
data: null,
error: { message: 'Too Many Requests', name: 'rate_limit_exceeded' },
})
.mockResolvedValueOnce({ data: { ok: true }, error: null });
const result = await withRateLimitRetry(fn, {
channel: 'test-error-response',
});
expect(fn).toHaveBeenCalledTimes(2);
expect(result).toEqual({ data: { ok: true }, error: null });
}, 15_000);
it('retries on a thrown rate-limit error', async () => {
const fn = vi
.fn()
.mockRejectedValueOnce(new Error('rate limit hit'))
.mockResolvedValueOnce({ data: 'ok', error: null });
const result = await withRateLimitRetry(fn, { channel: 'test-thrown' });
expect(fn).toHaveBeenCalledTimes(2);
expect(result).toEqual({ data: 'ok', error: null });
}, 15_000);
it('rethrows non-rate-limit errors immediately', async () => {
const fn = vi.fn().mockRejectedValue(new Error('schema mismatch'));
await expect(
withRateLimitRetry(fn, { channel: 'test-rethrow' }),
).rejects.toThrow('schema mismatch');
expect(fn).toHaveBeenCalledTimes(1);
});
it('uses independent throttle timestamps per channel', async () => {
let counterA = 0;
let counterB = 0;
const fnA = vi.fn(async () => ({ data: ++counterA, error: null }));
const fnB = vi.fn(async () => ({ data: ++counterB, error: null }));
await Promise.all([
withRateLimitRetry(fnA, { channel: 'channel-a' }),
withRateLimitRetry(fnB, { channel: 'channel-b' }),
]);
expect(fnA).toHaveBeenCalledTimes(1);
expect(fnB).toHaveBeenCalledTimes(1);
});
});
@@ -1,116 +0,0 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import {
fetchAllPaginated,
type ResendListFn,
} from 'src/modules/resend/shared/utils/fetch-all-paginated';
type Item = { id: string };
type ListResponse = Awaited<ReturnType<ResendListFn<Item>>>;
const page = (ids: string[], hasMore: boolean): ListResponse => ({
data: { data: ids.map((id) => ({ id })), has_more: hasMore },
error: null,
});
const makeListFn = (
pages: ListResponse[],
): { fn: ResendListFn<Item>; calls: { limit: number; after?: string }[] } => {
const calls: { limit: number; after?: string }[] = [];
let index = 0;
const fn: ResendListFn<Item> = async (params) => {
calls.push(params);
const result = pages[index] ?? page([], false);
index++;
return result;
};
return { fn, calls };
};
beforeEach(() => {
vi.spyOn(console, 'warn').mockImplementation(() => {});
});
afterEach(() => {
vi.restoreAllMocks();
});
describe('fetchAllPaginated', () => {
it('returns items from a single page when has_more is false', async () => {
const { fn, calls } = makeListFn([page(['a', 'b', 'c'], false)]);
const result = await fetchAllPaginated(fn);
expect(result.map((item) => item.id)).toEqual(['a', 'b', 'c']);
expect(calls).toHaveLength(1);
expect(calls[0]).toEqual({ limit: 100 });
});
it('follows cursor across pages and concatenates results', async () => {
const { fn, calls } = makeListFn([
page(['a', 'b'], true),
page(['c', 'd'], true),
page(['e'], false),
]);
const result = await fetchAllPaginated(fn);
expect(result.map((item) => item.id)).toEqual(['a', 'b', 'c', 'd', 'e']);
expect(calls).toEqual([
{ limit: 100 },
{ limit: 100, after: 'b' },
{ limit: 100, after: 'd' },
]);
});
it('stops when a page returns an empty array', async () => {
const { fn, calls } = makeListFn([
page(['a'], true),
page([], true),
page(['b'], false),
]);
const result = await fetchAllPaginated(fn);
expect(result.map((item) => item.id)).toEqual(['a']);
expect(calls).toHaveLength(2);
});
it('stops when data is null', async () => {
const { fn } = makeListFn([
page(['a'], true),
{ data: null, error: null },
]);
const result = await fetchAllPaginated(fn);
expect(result.map((item) => item.id)).toEqual(['a']);
});
it('includes label and cursor in the error message when listFn returns an error', async () => {
const fn: ResendListFn<Item> = async (params) => {
if (params.after === 'a') {
return { data: null, error: { code: 'boom' } };
}
return page(['a'], true);
};
await expect(fetchAllPaginated(fn, 'broadcasts')).rejects.toThrow(
/Resend list\[broadcasts\] failed at cursor=a: .*boom/,
);
});
it('throws when the cursor does not advance', async () => {
const { fn } = makeListFn([page(['a'], true), page(['a'], true)]);
await expect(fetchAllPaginated(fn, 'segments')).rejects.toThrow(
/Resend list\[segments\] cursor stuck at a/,
);
});
});
@@ -1,53 +0,0 @@
import { isDefined } from 'twenty-shared/utils';
import { withRateLimitRetry } from 'src/modules/resend/shared/utils/with-rate-limit-retry';
const PAGE_SIZE = 100;
export type ResendListFn<T> = (params: {
limit: number;
after?: string;
}) => Promise<{
data: { data: T[]; has_more: boolean } | null;
error: unknown;
}>;
export const fetchAllPaginated = async <T extends { id: string }>(
listFn: ResendListFn<T>,
label = 'items',
): Promise<T[]> => {
const items: T[] = [];
let cursor: string | undefined;
while (true) {
const params = {
limit: PAGE_SIZE,
...(isDefined(cursor) && { after: cursor }),
};
const response = await withRateLimitRetry(() => listFn(params));
if (isDefined(response.error)) {
throw new Error(
`Resend list[${label}] failed at cursor=${cursor ?? 'start'}: ${JSON.stringify(response.error)}`,
);
}
const page = response.data;
if (!isDefined(page) || page.data.length === 0) break;
items.push(...page.data);
if (!page.has_more) break;
const nextCursor = page.data[page.data.length - 1].id;
if (nextCursor === cursor) {
throw new Error(`Resend list[${label}] cursor stuck at ${nextCursor}`);
}
cursor = nextCursor;
}
return items;
};
@@ -1,6 +1,6 @@
import { isNonEmptyString } from '@sniptt/guards';
import { CoreApiClient } from 'twenty-client-sdk/core';
import { isDefined } from 'twenty-shared/utils';
import { isDefined } from '@utils/is-defined';
type PersonName = {
firstName?: string;
@@ -11,15 +11,30 @@ type PeopleConnection = {
edges: Array<{ node: { id: string } }>;
};
export const findOrCreatePerson = async (
client: CoreApiClient,
email: string | undefined | null,
name?: PersonName,
): Promise<string | undefined> => {
if (!isNonEmptyString(email)) {
return undefined;
}
const isUniqueViolationError = (error: unknown): boolean => {
const text =
error instanceof Error
? error.message
: typeof error === 'object' && error !== null
? JSON.stringify(error)
: typeof error === 'string'
? error
: '';
const lower = text.toLowerCase();
return (
lower.includes('duplicate') ||
lower.includes('unique constraint') ||
lower.includes('uniqueness') ||
lower.includes('already exists') ||
lower.includes('violates unique')
);
};
const findPersonByEmail = async (
client: CoreApiClient,
email: string,
): Promise<string | undefined> => {
const { people } = await client.query({
people: {
edges: { node: { id: true } },
@@ -34,29 +49,54 @@ export const findOrCreatePerson = async (
},
});
const existingPersonId = (people as PeopleConnection | undefined)?.edges[0]
?.node?.id;
return (people as PeopleConnection | undefined)?.edges[0]?.node?.id;
};
export const findOrCreatePerson = async (
client: CoreApiClient,
email: string | undefined | null,
name?: PersonName,
): Promise<string | undefined> => {
if (!isNonEmptyString(email)) {
return undefined;
}
const existingPersonId = await findPersonByEmail(client, email);
if (isDefined(existingPersonId)) {
return existingPersonId;
}
const { createPerson } = await client.mutation({
createPerson: {
__args: {
data: {
name: {
firstName: name?.firstName ?? '',
lastName: name?.lastName ?? '',
},
emails: {
primaryEmail: email,
try {
const { createPerson } = await client.mutation({
createPerson: {
__args: {
data: {
name: {
firstName: name?.firstName ?? '',
lastName: name?.lastName ?? '',
},
emails: {
primaryEmail: email,
},
},
},
id: true,
},
id: true,
},
});
});
return (createPerson as { id: string } | undefined)?.id;
return (createPerson as { id: string } | undefined)?.id;
} catch (createError) {
if (!isUniqueViolationError(createError)) {
throw createError;
}
const raceWinnerId = await findPersonByEmail(client, email);
if (isDefined(raceWinnerId)) {
return raceWinnerId;
}
throw createError;
}
};
@@ -0,0 +1,56 @@
import { isNonEmptyString } from '@sniptt/guards';
import { CoreApiClient } from 'twenty-client-sdk/core';
type PeopleConnection = {
edges: Array<{
node: { id: string; emails?: { primaryEmail?: string | null } | null };
}>;
};
const normalize = (email: string): string => email.trim().toLowerCase();
export const findPeopleByEmail = async (
client: CoreApiClient,
emails: ReadonlyArray<string | undefined | null>,
): Promise<Map<string, string>> => {
const personIdByEmail = new Map<string, string>();
const emailKeys = Array.from(
new Set(
emails
.filter((email): email is string => isNonEmptyString(email))
.map(normalize),
),
);
if (emailKeys.length === 0) return personIdByEmail;
const { people } = await client.query({
people: {
__args: {
filter: {
emails: {
primaryEmail: { in: emailKeys },
},
},
first: emailKeys.length,
},
edges: {
node: {
id: true,
emails: { primaryEmail: true },
},
},
},
});
for (const edge of (people as PeopleConnection | undefined)?.edges ?? []) {
const primaryEmail = edge.node.emails?.primaryEmail;
if (isNonEmptyString(primaryEmail)) {
personIdByEmail.set(normalize(primaryEmail), edge.node.id);
}
}
return personIdByEmail;
};
@@ -1,8 +1,8 @@
import { CoreApiClient } from 'twenty-client-sdk/core';
type ConnectionResult = {
edges: Array<{ node: { id: string; resendId: string } }>;
};
import { extractConnection } from '@modules/resend/shared/utils/typed-client';
type ResendIdNode = { id: string; resendId: string };
export const findRecordByResendId = async (
client: CoreApiClient,
@@ -26,9 +26,7 @@ export const findRecordByResendId = async (
},
});
const connection = (result as Record<string, unknown>)[objectNamePlural] as
| ConnectionResult
| undefined;
const connection = extractConnection<ResendIdNode>(result, objectNamePlural);
return connection?.edges[0]?.node.id;
return connection.edges[0]?.node.id;
};
@@ -0,0 +1,72 @@
import { isNonEmptyString } from '@sniptt/guards';
import { CoreApiClient } from 'twenty-client-sdk/core';
import { extractConnection } from '@modules/resend/shared/utils/typed-client';
export type ResendContactLookup = {
id: string;
personId: string | null;
};
type ResendContactNode = {
id: string;
personId?: string | null;
email?: { primaryEmail?: string | null } | null;
};
const normalize = (email: string): string => email.trim().toLowerCase();
export const findResendContactsByEmail = async (
client: CoreApiClient,
emails: ReadonlyArray<string | undefined | null>,
): Promise<Map<string, ResendContactLookup>> => {
const contactByEmail = new Map<string, ResendContactLookup>();
const emailKeys = Array.from(
new Set(
emails
.filter((email): email is string => isNonEmptyString(email))
.map(normalize),
),
);
if (emailKeys.length === 0) return contactByEmail;
const result = await client.query({
resendContacts: {
__args: {
filter: {
email: {
primaryEmail: { in: emailKeys },
},
},
first: emailKeys.length,
},
edges: {
node: {
id: true,
personId: true,
email: { primaryEmail: true },
},
},
},
});
const connection = extractConnection<ResendContactNode>(
result,
'resendContacts',
);
for (const edge of connection.edges) {
const primaryEmail = edge.node.email?.primaryEmail;
if (!isNonEmptyString(primaryEmail)) continue;
contactByEmail.set(normalize(primaryEmail), {
id: edge.node.id,
personId: edge.node.personId ?? null,
});
}
return contactByEmail;
};
@@ -0,0 +1,55 @@
import { CoreApiClient } from 'twenty-client-sdk/core';
import { isDefined } from '@utils/is-defined';
import { extractConnection } from '@modules/resend/shared/utils/typed-client';
type ResendIdNode = {
id: string;
resendId?: string | null;
};
export const findTwentyIdsByResendId = async (
client: CoreApiClient,
objectNamePlural: string,
resendIds: ReadonlyArray<string | undefined | null>,
): Promise<Map<string, string>> => {
const map = new Map<string, string>();
const filteredIds = Array.from(
new Set(
resendIds.filter(
(resendId): resendId is string =>
typeof resendId === 'string' && resendId.length > 0,
),
),
);
if (filteredIds.length === 0) return map;
const result = await client.query({
[objectNamePlural]: {
__args: {
filter: {
resendId: { in: filteredIds },
},
first: filteredIds.length,
},
edges: {
node: {
id: true,
resendId: true,
},
},
},
});
const connection = extractConnection<ResendIdNode>(result, objectNamePlural);
for (const edge of connection.edges) {
if (isDefined(edge.node.resendId)) {
map.set(edge.node.resendId, edge.node.id);
}
}
return map;
};
@@ -0,0 +1,121 @@
import { isDefined } from '@utils/is-defined';
import { RESEND_PAGE_SIZE } from '@modules/resend/constants/sync-config';
import { withRateLimitRetry } from '@modules/resend/shared/utils/with-rate-limit-retry';
export type ResendListFunction<T> = (paginationParameters: {
limit: number;
after?: string;
}) => Promise<{
data: { data: T[]; has_more: boolean } | null;
error: unknown;
}>;
export type ForEachPageOptions = {
startCursor?: string;
onCursorAdvance?: (cursor: string) => Promise<void>;
deadlineAtMs?: number;
};
export type OnPageResult = {
ok: boolean;
stop?: boolean;
errors?: ReadonlyArray<string>;
};
export type OnPageHandler<T> = (
items: T[],
pageNumber: number,
) => Promise<OnPageResult | void>;
export type ForEachPageResult = {
completed: boolean;
};
export const forEachPage = async <T extends { id: string }>(
listFunction: ResendListFunction<T>,
onPage: OnPageHandler<T>,
label = 'items',
options?: ForEachPageOptions,
): Promise<ForEachPageResult> => {
let cursor: string | undefined = options?.startCursor;
let pageNumber = 0;
let totalFetched = 0;
while (true) {
const paginationParameters = {
limit: RESEND_PAGE_SIZE,
...(isDefined(cursor) && cursor.length > 0 && { after: cursor }),
};
const response = await withRateLimitRetry(
() => listFunction(paginationParameters),
{ channel: label },
);
if (isDefined(response.error)) {
throw new Error(
`Resend list[${label}] failed at cursor=${cursor ?? 'start'}: ${JSON.stringify(response.error)}`,
);
}
const page = response.data;
if (!isDefined(page) || page.data.length === 0) {
return { completed: true };
}
pageNumber++;
totalFetched += page.data.length;
console.log(
`[resend] fetched ${label} page ${pageNumber} (size=${page.data.length}, total=${totalFetched}, has_more=${page.has_more})`,
);
const handlerResult = await onPage(page.data, pageNumber);
const pageOk = handlerResult?.ok ?? true;
const shouldStop = handlerResult?.stop === true;
if (!pageOk) {
const perItemErrors = handlerResult?.errors ?? [];
const detail =
perItemErrors.length > 0
? ` failures: ${perItemErrors.join(' | ')}`
: '';
console.warn(
`[resend] ${label} page ${pageNumber} had per-item failures at cursor=${cursor ?? 'start'}; advancing past the page.${detail}`,
);
}
const nextCursor = page.data[page.data.length - 1].id;
if (isDefined(options?.onCursorAdvance)) {
await options.onCursorAdvance(nextCursor);
}
if (shouldStop) {
return { completed: true };
}
if (!page.has_more) {
return { completed: true };
}
if (nextCursor === cursor) {
throw new Error(`Resend list[${label}] cursor stuck at ${nextCursor}`);
}
if (
isDefined(options?.deadlineAtMs) &&
Date.now() >= options.deadlineAtMs
) {
console.log(
`[resend] reached deadline for ${label} after page ${pageNumber}; stopping early (cursor will resume next tick)`,
);
return { completed: false };
}
cursor = nextCursor;
}
};
@@ -1,5 +1,5 @@
import { Resend } from 'resend';
import { isDefined } from 'twenty-shared/utils';
import { isDefined } from '@utils/is-defined';
export const getResendClient = (): Resend => {
const apiKey = process.env.RESEND_API_KEY;
@@ -1,4 +1,4 @@
import { isDefined } from 'twenty-shared/utils';
import { isDefined } from '@utils/is-defined';
export type LastEvent =
| 'SENT'
@@ -1,14 +1,32 @@
import type { EmailsField } from 'src/modules/resend/shared/types/emails-field';
import type { EmailsField } from '@modules/resend/shared/types/emails-field';
const extractEmailAddress = (raw: string): string => {
const trimmed = raw.trim();
const closingIndex = trimmed.lastIndexOf('>');
const openingIndex = trimmed.lastIndexOf('<', closingIndex);
if (openingIndex !== -1 && closingIndex > openingIndex) {
return trimmed.slice(openingIndex + 1, closingIndex).trim().toLowerCase();
}
return trimmed.toLowerCase();
};
export const toEmailsField = (
value: string | string[] | undefined | null,
): EmailsField => {
if (Array.isArray(value)) {
const normalized = value.map(extractEmailAddress);
return {
primaryEmail: value[0] ?? '',
additionalEmails: value.length > 1 ? value.slice(1) : null,
primaryEmail: normalized[0] ?? '',
additionalEmails: normalized.length > 1 ? normalized.slice(1) : null,
};
}
return { primaryEmail: value ?? '', additionalEmails: null };
return {
primaryEmail: typeof value === 'string' ? extractEmailAddress(value) : '',
additionalEmails: null,
};
};
@@ -1,4 +1,4 @@
import { isDefined } from 'twenty-shared/utils';
import { isDefined } from '@utils/is-defined';
export const toIsoString = (date: string): string =>
new Date(date).toISOString();
@@ -0,0 +1,47 @@
export type PageInfo = {
hasNextPage: boolean;
endCursor: string | null;
};
export type Connection<TNode> = {
edges: Array<{ node: TNode }>;
pageInfo?: PageInfo;
};
const isObject = (value: unknown): value is Record<string, unknown> =>
typeof value === 'object' && value !== null;
export const extractConnection = <TNode>(
result: unknown,
field: string,
): Connection<TNode> => {
if (!isObject(result)) {
return { edges: [] };
}
const candidate = result[field];
if (!isObject(candidate)) {
return { edges: [] };
}
return {
edges: Array.isArray((candidate as Connection<TNode>).edges)
? (candidate as Connection<TNode>).edges
: [],
pageInfo: (candidate as Connection<TNode>).pageInfo,
};
};
export const extractMutationRecord = <T = { id: string }>(
result: unknown,
field: string,
): T | undefined => {
if (!isObject(result)) return undefined;
const candidate = result[field];
if (!isObject(candidate)) return undefined;
return candidate as T;
};
@@ -1,3 +1,11 @@
import { isDefined } from '@utils/is-defined';
import {
RATE_LIMIT_BASE_DELAY_MS,
RATE_LIMIT_MAX_RETRIES,
RATE_LIMIT_MIN_INTERVAL_MS,
} from '@modules/resend/constants/sync-config';
const sleep = (ms: number) =>
new Promise<void>((resolve) => setTimeout(resolve, ms));
@@ -7,7 +15,9 @@ const isRateLimitError = (error: unknown): boolean => {
? error.message
: typeof error === 'object' && error !== null
? JSON.stringify(error)
: '';
: typeof error === 'string'
? error
: '';
const lower = text.toLowerCase();
return (
@@ -17,31 +27,63 @@ const isRateLimitError = (error: unknown): boolean => {
);
};
const MAX_RETRIES = 5;
const BASE_DELAY_MS = 1000;
const MIN_INTERVAL_MS = 220;
const isResendErrorResponse = (
value: unknown,
): value is { error: unknown; data?: unknown } => {
if (typeof value !== 'object' || value === null) return false;
let lastCallTimestamp = 0;
return (
'error' in value &&
isDefined((value as { error: unknown }).error)
);
};
const lastCallTimestampByChannel = new Map<string, number>();
const DEFAULT_CHANNEL = 'default';
export const withRateLimitRetry = async <T>(
fn: () => Promise<T>,
options?: { channel?: string },
): Promise<T> => {
for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
const channel = options?.channel ?? DEFAULT_CHANNEL;
for (let attempt = 0; attempt <= RATE_LIMIT_MAX_RETRIES; attempt++) {
const lastCallTimestamp = lastCallTimestampByChannel.get(channel) ?? 0;
const elapsed = Date.now() - lastCallTimestamp;
if (elapsed < MIN_INTERVAL_MS) await sleep(MIN_INTERVAL_MS - elapsed);
if (elapsed < RATE_LIMIT_MIN_INTERVAL_MS) {
await sleep(RATE_LIMIT_MIN_INTERVAL_MS - elapsed);
}
lastCallTimestamp = Date.now();
lastCallTimestampByChannel.set(channel, Date.now());
try {
return await fn();
} catch (error) {
if (!isRateLimitError(error) || attempt === MAX_RETRIES) throw error;
const result = await fn();
const delayMs = BASE_DELAY_MS * 2 ** attempt;
if (
isResendErrorResponse(result) &&
isRateLimitError(result.error) &&
attempt < RATE_LIMIT_MAX_RETRIES
) {
const delayMs = RATE_LIMIT_BASE_DELAY_MS * 2 ** attempt;
console.warn(
`[resend] Rate limited (response.error), retrying in ${delayMs}ms (attempt ${attempt + 1}/${RATE_LIMIT_MAX_RETRIES})`,
);
await sleep(delayMs);
continue;
}
return result;
} catch (error) {
if (!isRateLimitError(error) || attempt === RATE_LIMIT_MAX_RETRIES) {
throw error;
}
const delayMs = RATE_LIMIT_BASE_DELAY_MS * 2 ** attempt;
console.warn(
`[resend] Rate limited, retrying in ${delayMs}ms (attempt ${attempt + 1}/${MAX_RETRIES})`,
`[resend] Rate limited (thrown), retrying in ${delayMs}ms (attempt ${attempt + 1}/${RATE_LIMIT_MAX_RETRIES})`,
);
await sleep(delayMs);
}
@@ -0,0 +1,10 @@
import type { SyncCursorStep } from '@modules/resend/sync/cursor/types/sync-cursor-step';
export const RESEND_SYNC_CURSOR_STEPS: ReadonlyArray<SyncCursorStep> = [
'TOPICS',
'SEGMENTS',
'TEMPLATES',
'CONTACTS',
'EMAILS',
'BROADCASTS',
];
@@ -0,0 +1,13 @@
export type SyncCursorStep =
| 'SEGMENTS'
| 'TEMPLATES'
| 'CONTACTS'
| 'EMAILS'
| 'BROADCASTS'
| 'TOPICS';
export type SyncCursorRow = {
id: string;
step: SyncCursorStep;
cursor: string | null;
};
@@ -0,0 +1,88 @@
import { CoreApiClient } from 'twenty-client-sdk/core';
import { describe, expect, it, vi } from 'vitest';
import { resetAllSyncCursors } from '@modules/resend/sync/cursor/utils/reset-all-sync-cursors';
type CursorNode = {
id: string;
step?: string;
cursor?: string | null;
lastRunStatus?: 'SUCCESS' | 'FAILED' | 'IN_PROGRESS' | null;
};
const makeClient = (nodes: CursorNode[]) => {
const mutationCalls: Array<Record<string, unknown>> = [];
const query = vi.fn(async () => ({
resendSyncCursors: {
edges: nodes.map((node) => ({ node })),
},
}));
const mutation = vi.fn(async (m: Record<string, unknown>) => {
mutationCalls.push(m);
return { updateResendSyncCursor: { id: 'updated' } };
});
const client = { query, mutation } as unknown as CoreApiClient;
return { client, mutationCalls, query, mutation };
};
const extractUpdateArgs = (call: Record<string, unknown>) => {
const updateBlock = call.updateResendSyncCursor as
| { __args?: { id?: string; data?: Record<string, unknown> } }
| undefined;
return updateBlock?.__args;
};
describe('resetAllSyncCursors', () => {
it('issues no mutations when there are no rows', async () => {
const { client, mutation } = makeClient([]);
await resetAllSyncCursors(client);
expect(mutation).not.toHaveBeenCalled();
});
it('resets every returned row with cursor/lastRunAt/lastRunStatus nulled', async () => {
const nodes: CursorNode[] = [
{ id: 'row-1', step: 'TOPICS', cursor: null, lastRunStatus: 'SUCCESS' },
{ id: 'row-2', step: 'EMAILS', cursor: 'mid', lastRunStatus: 'IN_PROGRESS' },
{ id: 'row-3', step: 'BROADCASTS', cursor: null, lastRunStatus: 'FAILED' },
];
const { client, mutationCalls } = makeClient(nodes);
await resetAllSyncCursors(client);
expect(mutationCalls).toHaveLength(3);
const updatedIds = mutationCalls
.map((call) => extractUpdateArgs(call)?.id)
.sort();
expect(updatedIds).toEqual(['row-1', 'row-2', 'row-3']);
for (const call of mutationCalls) {
expect(extractUpdateArgs(call)?.data).toEqual({
cursor: null,
lastRunAt: null,
lastRunStatus: null,
});
}
});
it('skips edges whose node has no id', async () => {
const { client, mutationCalls } = makeClient([
{ id: 'row-1' },
{ id: '' },
]);
await resetAllSyncCursors(client);
expect(mutationCalls).toHaveLength(1);
expect(extractUpdateArgs(mutationCalls[0])?.id).toBe('row-1');
});
});
@@ -0,0 +1,168 @@
import { CoreApiClient } from 'twenty-client-sdk/core';
import { describe, expect, it, vi } from 'vitest';
import { withSyncCursor } from 'src/modules/resend/sync/cursor/utils/with-sync-cursor';
type CursorNode = {
id: string;
step: string;
cursor: string | null;
};
const makeClient = (existingNode?: CursorNode) => {
const mutationCalls: Array<Record<string, unknown>> = [];
const queryCalls: Array<Record<string, unknown>> = [];
const query = vi.fn(async (q: Record<string, unknown>) => {
queryCalls.push(q);
return {
resendSyncCursors: {
edges: existingNode ? [{ node: existingNode }] : [],
},
};
});
const mutation = vi.fn(async (m: Record<string, unknown>) => {
mutationCalls.push(m);
if ('createResendSyncCursor' in m) {
return { createResendSyncCursor: { id: 'created-cursor-id' } };
}
return { updateResendSyncCursor: { id: 'updated' } };
});
const client = { query, mutation } as unknown as CoreApiClient;
return { client, mutationCalls, queryCalls };
};
const findUpdate = (
calls: Array<Record<string, unknown>>,
predicate: (data: Record<string, unknown>) => boolean,
): Record<string, unknown> | undefined => {
for (const call of calls) {
const updateBlock = call.updateResendSyncCursor as
| { __args?: { data?: Record<string, unknown> } }
| undefined;
const data = updateBlock?.__args?.data;
if (data !== undefined && predicate(data)) {
return data;
}
}
return undefined;
};
describe('withSyncCursor', () => {
it('creates a cursor row when none exists, then clears the cursor on success', async () => {
const { client, mutationCalls } = makeClient();
await withSyncCursor(client, 'SEGMENTS', async ({ resumeCursor }) => {
expect(resumeCursor).toBeUndefined();
return { value: undefined, completed: true };
});
const create = mutationCalls.find((c) => 'createResendSyncCursor' in c);
expect(create).toBeDefined();
const clearedUpdate = findUpdate(
mutationCalls,
(data) => data.cursor === null,
);
expect(clearedUpdate).toBeDefined();
});
it('resumes from stored cursor when one is persisted', async () => {
const { client } = makeClient({
id: 'cursor-1',
step: 'CONTACTS',
cursor: 'last-id',
});
const seen: { resumeCursor: string | undefined }[] = [];
await withSyncCursor(client, 'CONTACTS', async (ctx) => {
seen.push({ resumeCursor: ctx.resumeCursor });
return { value: undefined, completed: true };
});
expect(seen).toEqual([{ resumeCursor: 'last-id' }]);
});
it('persists progress via onCursorAdvance', async () => {
const { client, mutationCalls } = makeClient({
id: 'cursor-1',
step: 'EMAILS',
cursor: null,
});
await withSyncCursor(client, 'EMAILS', async ({ onCursorAdvance }) => {
await onCursorAdvance('item-99');
return { value: undefined, completed: false };
});
const progress = findUpdate(
mutationCalls,
(data) => data.cursor === 'item-99',
);
expect(progress).toBeDefined();
});
it('preserves the resume cursor when preserveCursor=true and run completes', async () => {
const { client, mutationCalls } = makeClient({
id: 'cursor-1',
step: 'EMAILS',
cursor: 'in-progress-id',
});
await withSyncCursor(
client,
'EMAILS',
async ({ onCursorAdvance }) => {
await onCursorAdvance('item-99');
return { value: undefined, completed: true };
},
{ preserveCursor: true },
);
const cursorWriteAttempt = findUpdate(
mutationCalls,
(data) =>
Object.prototype.hasOwnProperty.call(data, 'cursor'),
);
expect(cursorWriteAttempt).toBeUndefined();
});
it('rethrows and leaves the cursor untouched when fn throws', async () => {
const { client, mutationCalls } = makeClient({
id: 'cursor-1',
step: 'CONTACTS',
cursor: 'last-id',
});
await expect(
withSyncCursor(client, 'CONTACTS', async () => {
throw new Error('boom');
}),
).rejects.toThrow('boom');
const clearedUpdate = findUpdate(
mutationCalls,
(data) => data.cursor === null,
);
expect(clearedUpdate).toBeUndefined();
});
});
@@ -0,0 +1,130 @@
import { CoreApiClient } from 'twenty-client-sdk/core';
import { isDefined } from '@utils/is-defined';
import type {
SyncCursorRow,
SyncCursorStep,
} from 'src/modules/resend/sync/cursor/types/sync-cursor-step';
import {
extractConnection,
extractMutationRecord,
} from '@modules/resend/shared/utils/typed-client';
type SyncCursorNode = {
id: string;
step: SyncCursorStep;
cursor: string | null;
};
const findExistingCursor = async (
client: CoreApiClient,
step: SyncCursorStep,
): Promise<SyncCursorRow | null> => {
const queryResult = await client.query({
resendSyncCursors: {
__args: {
filter: {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
step: { eq: step as any },
},
first: 1,
},
edges: {
node: {
id: true,
step: true,
cursor: true,
},
},
},
});
const connection = extractConnection<SyncCursorNode>(
queryResult,
'resendSyncCursors',
);
const existingNode = connection.edges[0]?.node;
if (!isDefined(existingNode)) {
return null;
}
return {
id: existingNode.id,
step: existingNode.step,
cursor: existingNode.cursor,
};
};
export const getOrCreateSyncCursor = async (
client: CoreApiClient,
step: SyncCursorStep,
): Promise<SyncCursorRow> => {
const existing = await findExistingCursor(client, step);
if (isDefined(existing)) {
return existing;
}
try {
const createResult = await client.mutation({
createResendSyncCursor: {
__args: {
data: {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
step: step as any,
},
},
id: true,
},
});
const created = extractMutationRecord<{ id: string }>(
createResult,
'createResendSyncCursor',
);
if (!isDefined(created)) {
throw new Error(`Failed to create resendSyncCursor for step ${step}`);
}
return {
id: created.id,
step,
cursor: null,
};
} catch (createError) {
if (!isUniqueViolationError(createError)) {
throw createError;
}
const raceWinner = await findExistingCursor(client, step);
if (isDefined(raceWinner)) {
return raceWinner;
}
throw createError;
}
};
const isUniqueViolationError = (error: unknown): boolean => {
const text =
error instanceof Error
? error.message
: typeof error === 'object' && error !== null
? JSON.stringify(error)
: typeof error === 'string'
? error
: '';
const lower = text.toLowerCase();
return (
lower.includes('duplicate') ||
lower.includes('unique constraint') ||
lower.includes('uniqueness') ||
lower.includes('already exists') ||
lower.includes('violates unique')
);
};
@@ -0,0 +1,46 @@
import { CoreApiClient } from 'twenty-client-sdk/core';
import { isDefined } from '@utils/is-defined';
import { extractConnection } from '@modules/resend/shared/utils/typed-client';
import { RESEND_SYNC_CURSOR_STEPS } from '@modules/resend/sync/cursor/constants/resend-sync-cursor-steps';
import { updateCursorRow } from '@modules/resend/sync/cursor/utils/set-cursor';
type SyncCursorIdNode = {
id: string;
};
export const resetAllSyncCursors = async (
client: CoreApiClient,
): Promise<void> => {
const queryResult = await client.query({
resendSyncCursors: {
__args: {
first: RESEND_SYNC_CURSOR_STEPS.length + 5,
},
edges: {
node: {
id: true,
},
},
},
});
const connection = extractConnection<SyncCursorIdNode>(
queryResult,
'resendSyncCursors',
);
const ids = connection.edges
.map((edge) => edge.node?.id)
.filter((id): id is string => isDefined(id) && id.length > 0);
await Promise.all(
ids.map((id) =>
updateCursorRow(client, id, {
cursor: null,
lastRunAt: null,
lastRunStatus: null,
}),
),
);
};
@@ -0,0 +1,35 @@
import { CoreApiClient } from 'twenty-client-sdk/core';
export type SyncRunStatus = 'SUCCESS' | 'FAILED' | 'IN_PROGRESS';
type CursorUpdate = {
cursor?: string | null;
lastRunAt?: string | null;
lastRunStatus?: SyncRunStatus | null;
};
export const setCursor = async (
client: CoreApiClient,
id: string,
cursor: string | null,
): Promise<void> => {
await client.mutation({
updateResendSyncCursor: {
__args: { id, data: { cursor } },
id: true,
},
});
};
export const updateCursorRow = async (
client: CoreApiClient,
id: string,
data: CursorUpdate,
): Promise<void> => {
await client.mutation({
updateResendSyncCursor: {
__args: { id, data },
id: true,
},
});
};
@@ -0,0 +1,80 @@
import { CoreApiClient } from 'twenty-client-sdk/core';
import { isDefined } from '@utils/is-defined';
import { getOrCreateSyncCursor } from 'src/modules/resend/sync/cursor/utils/get-or-create-sync-cursor';
import {
setCursor,
updateCursorRow,
} from 'src/modules/resend/sync/cursor/utils/set-cursor';
import type { SyncCursorStep } from 'src/modules/resend/sync/cursor/types/sync-cursor-step';
export type SyncCursorContext = {
resumeCursor: string | undefined;
onCursorAdvance: (cursor: string) => Promise<void>;
};
export type SyncCursorRunResult<TValue> = {
value: TValue;
completed: boolean;
};
export type WithSyncCursorOptions = {
preserveCursor?: boolean;
};
export const withSyncCursor = async <TValue>(
client: CoreApiClient,
step: SyncCursorStep,
runWithCursor: (
context: SyncCursorContext,
) => Promise<SyncCursorRunResult<TValue>>,
options?: WithSyncCursorOptions,
): Promise<TValue> => {
const cursorRow = await getOrCreateSyncCursor(client, step);
const startedAt = new Date().toISOString();
const preserveCursor = options?.preserveCursor === true;
await updateCursorRow(client, cursorRow.id, {
lastRunAt: startedAt,
lastRunStatus: 'IN_PROGRESS',
});
const hasResumeCursor =
isDefined(cursorRow.cursor) && cursorRow.cursor.length > 0;
if (hasResumeCursor) {
console.log(
`[sync] resuming step ${step} from cursor ${cursorRow.cursor}`,
);
}
const context: SyncCursorContext = {
resumeCursor: hasResumeCursor ? cursorRow.cursor ?? undefined : undefined,
onCursorAdvance: preserveCursor
? async () => undefined
: (cursor) => setCursor(client, cursorRow.id, cursor),
};
try {
const { value, completed } = await runWithCursor(context);
if (completed) {
await updateCursorRow(client, cursorRow.id, {
...(preserveCursor ? {} : { cursor: null }),
lastRunStatus: 'SUCCESS',
});
} else {
await updateCursorRow(client, cursorRow.id, {
lastRunStatus: 'IN_PROGRESS',
});
}
return value;
} catch (runError) {
await updateCursorRow(client, cursorRow.id, {
lastRunStatus: 'FAILED',
});
throw runError;
}
};
@@ -0,0 +1,73 @@
import { CoreApiClient } from 'twenty-client-sdk/core';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { resendInitialSyncModeMonitorHandler } from '@modules/resend/sync/logic-functions/resend-initial-sync-mode-monitor';
const mockSetInitialSyncMode = vi.fn();
const mockAreAllSyncCursorsEmpty = vi.fn();
vi.mock('twenty-client-sdk/core', () => ({
CoreApiClient: vi.fn(),
}));
vi.mock('twenty-client-sdk/metadata', () => ({
MetadataApiClient: vi.fn(),
}));
vi.mock('@modules/resend/sync/utils/set-initial-sync-mode', () => ({
setInitialSyncMode: (...args: unknown[]) => mockSetInitialSyncMode(...args),
isInitialSyncModeOn: async () => process.env.INITIAL_SYNC_MODE === 'true',
}));
vi.mock('@modules/resend/sync/utils/are-all-sync-cursors-empty', () => ({
areAllSyncCursorsEmpty: (...args: unknown[]) =>
mockAreAllSyncCursorsEmpty(...args),
}));
describe('resendInitialSyncModeMonitorHandler', () => {
const originalEnv = process.env.INITIAL_SYNC_MODE;
beforeEach(() => {
mockSetInitialSyncMode.mockReset();
mockAreAllSyncCursorsEmpty.mockReset();
(CoreApiClient as unknown as ReturnType<typeof vi.fn>).mockReset();
});
afterEach(() => {
if (originalEnv === undefined) {
delete process.env.INITIAL_SYNC_MODE;
} else {
process.env.INITIAL_SYNC_MODE = originalEnv;
}
});
it('skips when INITIAL_SYNC_MODE is off', async () => {
process.env.INITIAL_SYNC_MODE = 'false';
const result = await resendInitialSyncModeMonitorHandler();
expect(result).toEqual({ skipped: true, flipped: false });
expect(mockAreAllSyncCursorsEmpty).not.toHaveBeenCalled();
expect(mockSetInitialSyncMode).not.toHaveBeenCalled();
});
it('does nothing when cursors are not all empty', async () => {
process.env.INITIAL_SYNC_MODE = 'true';
mockAreAllSyncCursorsEmpty.mockResolvedValue(false);
const result = await resendInitialSyncModeMonitorHandler();
expect(result).toEqual({ skipped: false, flipped: false });
expect(mockSetInitialSyncMode).not.toHaveBeenCalled();
});
it('flips INITIAL_SYNC_MODE to false when all cursors are empty', async () => {
process.env.INITIAL_SYNC_MODE = 'true';
mockAreAllSyncCursorsEmpty.mockResolvedValue(true);
const result = await resendInitialSyncModeMonitorHandler();
expect(result).toEqual({ skipped: false, flipped: true });
expect(mockSetInitialSyncMode).toHaveBeenCalledWith('false');
});
});
@@ -0,0 +1,109 @@
import { CoreApiClient } from 'twenty-client-sdk/core';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { resendSyncBroadcastsAndDependenciesHandler } from '@modules/resend/sync/logic-functions/resend-sync-broadcasts-and-dependencies';
const mockSyncTopics = vi.fn();
const mockSyncSegments = vi.fn();
const mockSyncBroadcasts = vi.fn();
const mockGetResendClient = vi.fn();
vi.mock('twenty-client-sdk/core', () => ({
CoreApiClient: vi.fn(),
}));
vi.mock('@modules/resend/shared/utils/get-resend-client', () => ({
getResendClient: () => mockGetResendClient(),
}));
vi.mock('@modules/resend/sync/utils/sync-topics', () => ({
syncTopics: (...args: unknown[]) => mockSyncTopics(...args),
}));
vi.mock('@modules/resend/sync/utils/sync-segments', () => ({
syncSegments: (...args: unknown[]) => mockSyncSegments(...args),
}));
vi.mock('@modules/resend/sync/utils/sync-broadcasts', () => ({
syncBroadcasts: (...args: unknown[]) => mockSyncBroadcasts(...args),
}));
const okResult = <T>(value: T) => ({
result: { fetched: 0, created: 0, updated: 0, errors: [] },
value,
});
describe('resendSyncBroadcastsAndDependenciesHandler', () => {
beforeEach(() => {
mockSyncTopics.mockReset();
mockSyncSegments.mockReset();
mockSyncBroadcasts.mockReset();
mockGetResendClient.mockReset();
(CoreApiClient as unknown as ReturnType<typeof vi.fn>).mockReset();
mockGetResendClient.mockReturnValue({});
});
it('runs topics → segments → broadcasts in sequence with deadline propagation', async () => {
const topicMap = new Map([['topic-1', 'twenty-topic-1']]);
const segmentMap = new Map([['segment-1', 'twenty-segment-1']]);
mockSyncTopics.mockResolvedValue(okResult(topicMap));
mockSyncSegments.mockResolvedValue(okResult(segmentMap));
mockSyncBroadcasts.mockResolvedValue(okResult(undefined));
const summary = await resendSyncBroadcastsAndDependenciesHandler();
expect(mockSyncTopics).toHaveBeenCalledTimes(1);
expect(mockSyncSegments).toHaveBeenCalledTimes(1);
expect(mockSyncBroadcasts).toHaveBeenCalledTimes(1);
const broadcastsArgs = mockSyncBroadcasts.mock.calls[0];
expect(broadcastsArgs).toHaveLength(3);
expect(broadcastsArgs[2]).toEqual({ deadlineAtMs: expect.any(Number) });
const topicsArgs = mockSyncTopics.mock.calls[0];
expect(topicsArgs[3]).toEqual({ deadlineAtMs: expect.any(Number) });
const segmentsArgs = mockSyncSegments.mock.calls[0];
expect(segmentsArgs[3]).toEqual({ deadlineAtMs: expect.any(Number) });
expect(summary.steps.map((s) => s.name)).toEqual([
'TOPICS',
'SEGMENTS',
'BROADCASTS',
]);
expect(summary.steps.map((s) => s.status)).toEqual(['ok', 'ok', 'ok']);
});
it('skips broadcasts when topics fails', async () => {
mockSyncTopics.mockRejectedValue(new Error('topics boom'));
mockSyncSegments.mockResolvedValue(okResult(new Map()));
const summary = await resendSyncBroadcastsAndDependenciesHandler();
expect(mockSyncBroadcasts).not.toHaveBeenCalled();
const statusByName = new Map(summary.steps.map((s) => [s.name, s.status]));
expect(statusByName.get('TOPICS')).toBe('failed');
expect(statusByName.get('BROADCASTS')).toBe('skipped');
});
it('skips broadcasts when segments fails', async () => {
mockSyncTopics.mockResolvedValue(okResult(new Map()));
mockSyncSegments.mockRejectedValue(new Error('segments boom'));
const summary = await resendSyncBroadcastsAndDependenciesHandler();
expect(mockSyncBroadcasts).not.toHaveBeenCalled();
const statusByName = new Map(summary.steps.map((s) => [s.name, s.status]));
expect(statusByName.get('SEGMENTS')).toBe('failed');
expect(statusByName.get('BROADCASTS')).toBe('skipped');
});
});
@@ -0,0 +1,43 @@
import { CoreApiClient } from 'twenty-client-sdk/core';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { resendSyncContactsHandler } from '@modules/resend/sync/logic-functions/resend-sync-contacts';
const mockSyncContacts = vi.fn();
vi.mock('twenty-client-sdk/core', () => ({
CoreApiClient: vi.fn(),
}));
vi.mock('@modules/resend/shared/utils/get-resend-client', () => ({
getResendClient: () => ({}),
}));
vi.mock('@modules/resend/sync/utils/sync-contacts', () => ({
syncContacts: (...args: unknown[]) => mockSyncContacts(...args),
}));
describe('resendSyncContactsHandler', () => {
beforeEach(() => {
mockSyncContacts.mockReset();
(CoreApiClient as unknown as ReturnType<typeof vi.fn>).mockReset();
});
it('invokes syncContacts with a deadline and reports an ok CONTACTS step', async () => {
mockSyncContacts.mockResolvedValue({
result: { fetched: 1, created: 1, updated: 0, errors: [] },
value: undefined,
});
const summary = await resendSyncContactsHandler();
expect(mockSyncContacts).toHaveBeenCalledTimes(1);
const args = mockSyncContacts.mock.calls[0];
expect(args[3]).toEqual({ deadlineAtMs: expect.any(Number) });
expect(args[3].deadlineAtMs).toBeGreaterThan(Date.now());
expect(summary.steps).toEqual([
expect.objectContaining({ name: 'CONTACTS', status: 'ok', created: 1 }),
]);
});
});
@@ -0,0 +1,74 @@
import { CoreApiClient } from 'twenty-client-sdk/core';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { INTERMEDIATE_SYNC_EMAILS_MAX_AGE_MS } from '@modules/resend/constants/sync-config';
import { resendSyncEmailsHandler } from '@modules/resend/sync/logic-functions/resend-sync-emails';
const mockSyncEmails = vi.fn();
vi.mock('twenty-client-sdk/core', () => ({
CoreApiClient: vi.fn(),
}));
vi.mock('@modules/resend/shared/utils/get-resend-client', () => ({
getResendClient: () => ({}),
}));
vi.mock('@modules/resend/sync/utils/set-initial-sync-mode', () => ({
isInitialSyncModeOn: async () => process.env.INITIAL_SYNC_MODE === 'true',
setInitialSyncMode: vi.fn(),
}));
vi.mock('@modules/resend/sync/utils/sync-emails', () => ({
syncEmails: (...args: unknown[]) => mockSyncEmails(...args),
}));
describe('resendSyncEmailsHandler', () => {
const originalMode = process.env.INITIAL_SYNC_MODE;
beforeEach(() => {
mockSyncEmails.mockReset();
mockSyncEmails.mockResolvedValue({
result: { fetched: 0, created: 0, updated: 0, errors: [] },
value: undefined,
});
(CoreApiClient as unknown as ReturnType<typeof vi.fn>).mockReset();
});
afterEach(() => {
if (originalMode === undefined) {
delete process.env.INITIAL_SYNC_MODE;
} else {
process.env.INITIAL_SYNC_MODE = originalMode;
}
});
it('runs a full resumable sync in initial mode and forwards a deadline', async () => {
process.env.INITIAL_SYNC_MODE = 'true';
await resendSyncEmailsHandler();
expect(mockSyncEmails).toHaveBeenCalledTimes(1);
const args = mockSyncEmails.mock.calls[0];
expect(args).toHaveLength(4);
expect(args[3]).toEqual({ deadlineAtMs: expect.any(Number) });
expect(args[3].deadlineAtMs).toBeGreaterThan(Date.now());
});
it('runs a 7-day non-resumable sync in intermediate mode and forwards a deadline', async () => {
process.env.INITIAL_SYNC_MODE = 'false';
await resendSyncEmailsHandler();
expect(mockSyncEmails).toHaveBeenCalledTimes(1);
const args = mockSyncEmails.mock.calls[0];
expect(args[3]).toEqual({
stopBeforeCreatedAtMs: INTERMEDIATE_SYNC_EMAILS_MAX_AGE_MS,
resumable: false,
deadlineAtMs: expect.any(Number),
});
expect(args[3].deadlineAtMs).toBeGreaterThan(Date.now());
});
});
@@ -0,0 +1,59 @@
import { CoreApiClient } from 'twenty-client-sdk/core';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { resendSyncTemplatesHandler } from '@modules/resend/sync/logic-functions/resend-sync-templates';
const mockSyncTemplates = vi.fn();
vi.mock('twenty-client-sdk/core', () => ({
CoreApiClient: vi.fn(),
}));
vi.mock('@modules/resend/shared/utils/get-resend-client', () => ({
getResendClient: () => ({}),
}));
vi.mock('@modules/resend/sync/utils/sync-templates', () => ({
syncTemplates: (...args: unknown[]) => mockSyncTemplates(...args),
}));
describe('resendSyncTemplatesHandler', () => {
beforeEach(() => {
mockSyncTemplates.mockReset();
(CoreApiClient as unknown as ReturnType<typeof vi.fn>).mockReset();
});
it('reports an ok TEMPLATES step on success and forwards a deadline', async () => {
mockSyncTemplates.mockResolvedValue({
result: { fetched: 5, created: 2, updated: 3, errors: [] },
value: undefined,
});
const summary = await resendSyncTemplatesHandler();
expect(mockSyncTemplates).toHaveBeenCalledTimes(1);
const args = mockSyncTemplates.mock.calls[0];
expect(args[2]).toEqual({ deadlineAtMs: expect.any(Number) });
expect(args[2].deadlineAtMs).toBeGreaterThan(Date.now());
expect(summary.steps).toEqual([
expect.objectContaining({
name: 'TEMPLATES',
status: 'ok',
fetched: 5,
created: 2,
updated: 3,
}),
]);
});
it('reports a failed TEMPLATES step when sync throws', async () => {
mockSyncTemplates.mockRejectedValue(new Error('boom'));
const summary = await resendSyncTemplatesHandler();
expect(summary.steps).toEqual([
expect.objectContaining({ name: 'TEMPLATES', status: 'failed' }),
]);
});
});
@@ -1,12 +1,12 @@
import { isNonEmptyString } from '@sniptt/guards';
import { CoreApiClient } from 'twenty-client-sdk/core';
import { defineLogicFunction, type DatabaseEventPayload, type ObjectRecordCreateEvent } from 'twenty-sdk/define';
import { isDefined } from 'twenty-shared/utils';
import { isDefined } from '@utils/is-defined';
import { ON_RESEND_CONTACT_CREATED_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER } from 'src/modules/resend/constants/universal-identifiers';
import type { ResendContactRecord } from 'src/modules/resend/shared/types/resend-contact-record';
import { findOrCreatePerson } from 'src/modules/resend/shared/utils/find-or-create-person';
import { getResendClient } from 'src/modules/resend/shared/utils/get-resend-client';
import { ON_RESEND_CONTACT_CREATED_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER } from '@modules/resend/constants/universal-identifiers';
import type { ResendContactRecord } from '@modules/resend/shared/types/resend-contact-record';
import { findOrCreatePerson } from '@modules/resend/shared/utils/find-or-create-person';
import { getResendClient } from '@modules/resend/shared/utils/get-resend-client';
type ContactCreateEvent = DatabaseEventPayload<
ObjectRecordCreateEvent<ResendContactRecord>
@@ -27,9 +27,9 @@ const handler = async (
return { skipped: true, reason: 'no email on record' };
}
const resend = getResendClient();
const resendClient = getResendClient();
const { data, error } = await resend.contacts.create({
const { data, error } = await resendClient.contacts.create({
email,
firstName: after.name?.firstName ?? undefined,
lastName: after.name?.lastName ?? undefined,
@@ -1,17 +1,17 @@
import { isNonEmptyString } from '@sniptt/guards';
import { defineLogicFunction, type DatabaseEventPayload, type ObjectRecordDeleteEvent } from 'twenty-sdk/define';
import { isDefined } from 'twenty-shared/utils';
import { defineLogicFunction, type DatabaseEventPayload, type ObjectRecordDestroyEvent } from 'twenty-sdk/define';
import { isDefined } from '@utils/is-defined';
import { ON_RESEND_CONTACT_DELETED_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER } from 'src/modules/resend/constants/universal-identifiers';
import type { ResendContactRecord } from 'src/modules/resend/shared/types/resend-contact-record';
import { getResendClient } from 'src/modules/resend/shared/utils/get-resend-client';
import { ON_RESEND_CONTACT_DESTROYED_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER } from '@modules/resend/constants/universal-identifiers';
import type { ResendContactRecord } from '@modules/resend/shared/types/resend-contact-record';
import { getResendClient } from '@modules/resend/shared/utils/get-resend-client';
type ContactDeleteEvent = DatabaseEventPayload<
ObjectRecordDeleteEvent<ResendContactRecord>
type ContactDestroyEvent = DatabaseEventPayload<
ObjectRecordDestroyEvent<ResendContactRecord>
>;
const handler = async (
event: ContactDeleteEvent,
event: ContactDestroyEvent,
): Promise<object | undefined> => {
const resendId = event.properties.before?.resendId;
@@ -35,17 +35,17 @@ const handler = async (
);
}
return { synced: true, resendId, action: 'deleted' };
return { synced: true, resendId, action: 'destroyed' };
};
export default defineLogicFunction({
universalIdentifier: ON_RESEND_CONTACT_DELETED_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER,
name: 'on-resend-contact-deleted',
universalIdentifier: ON_RESEND_CONTACT_DESTROYED_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER,
name: 'on-resend-contact-destroyed',
description:
'Removes a contact from Resend when a resendContact record is deleted in Twenty',
'Removes a contact from Resend when a resendContact record is permanently destroyed in Twenty',
timeoutSeconds: 30,
handler,
databaseEventTriggerSettings: {
eventName: 'resendContact.deleted',
eventName: 'resendContact.destroyed',
},
});
@@ -1,45 +1,62 @@
import { isNonEmptyString } from '@sniptt/guards';
import { CoreApiClient } from 'twenty-client-sdk/core';
import { defineLogicFunction, type DatabaseEventPayload, type ObjectRecordUpdateEvent } from 'twenty-sdk/define';
import { isDefined } from 'twenty-shared/utils';
import { isDefined } from '@utils/is-defined';
import { ON_RESEND_CONTACT_UPDATED_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER } from 'src/modules/resend/constants/universal-identifiers';
import type { ResendContactRecord } from 'src/modules/resend/shared/types/resend-contact-record';
import { findOrCreatePerson } from 'src/modules/resend/shared/utils/find-or-create-person';
import { getResendClient } from 'src/modules/resend/shared/utils/get-resend-client';
import { ON_RESEND_CONTACT_UPDATED_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER } from '@modules/resend/constants/universal-identifiers';
import type { ResendContactRecord } from '@modules/resend/shared/types/resend-contact-record';
import { findOrCreatePerson } from '@modules/resend/shared/utils/find-or-create-person';
import { getResendClient } from '@modules/resend/shared/utils/get-resend-client';
type ContactUpdateEvent = DatabaseEventPayload<
ObjectRecordUpdateEvent<ResendContactRecord>
>;
const valuesEqual = (a: unknown, b: unknown): boolean =>
JSON.stringify(a ?? null) === JSON.stringify(b ?? null);
const handler = async (
event: ContactUpdateEvent,
): Promise<object | undefined> => {
if (event.properties.updatedFields?.includes('lastSyncedFromResend')) {
const { before, after } = event.properties;
const lastSyncedChanged = !valuesEqual(
before?.lastSyncedFromResend,
after?.lastSyncedFromResend,
);
const unsubscribedChanged = !valuesEqual(
before?.unsubscribed,
after?.unsubscribed,
);
const nameChanged = !valuesEqual(before?.name, after?.name);
const emailChanged = !valuesEqual(before?.email, after?.email);
const userFieldsChanged = unsubscribedChanged || nameChanged || emailChanged;
if (lastSyncedChanged && !userFieldsChanged) {
return { skipped: true, reason: 'inbound sync echo' };
}
const { after } = event.properties;
const resendId = after?.resendId;
if (!isNonEmptyString(resendId)) {
return { skipped: true, reason: 'no resendId on record' };
}
const resend = getResendClient();
const resendClient = getResendClient();
const updatePayload: Record<string, unknown> = { id: resendId };
if (event.properties.updatedFields?.includes('unsubscribed')) {
if (unsubscribedChanged) {
updatePayload.unsubscribed = after.unsubscribed;
}
if (event.properties.updatedFields?.includes('name')) {
if (nameChanged) {
updatePayload.firstName = after.name?.firstName ?? null;
updatePayload.lastName = after.name?.lastName ?? null;
}
if (event.properties.updatedFields?.includes('email')) {
if (emailChanged) {
updatePayload.email = after.email?.primaryEmail;
}
@@ -47,8 +64,8 @@ const handler = async (
return { skipped: true, reason: 'no relevant fields changed' };
}
const { error } = await resend.contacts.update(
updatePayload as Parameters<typeof resend.contacts.update>[0],
const { error } = await resendClient.contacts.update(
updatePayload as Parameters<typeof resendClient.contacts.update>[0],
);
if (isDefined(error)) {
@@ -59,7 +76,7 @@ const handler = async (
let personId: string | undefined;
if (event.properties.updatedFields?.includes('email')) {
if (emailChanged) {
const email = after.email?.primaryEmail;
const client = new CoreApiClient();
@@ -81,7 +98,9 @@ const handler = async (
return {
synced: true,
resendId,
updatedFields: Object.keys(updatePayload).filter((k) => k !== 'id'),
updatedFields: Object.keys(updatePayload).filter(
(payloadKey) => payloadKey !== 'id',
),
personId,
};
};
@@ -2,10 +2,10 @@ import { isNonEmptyString } from '@sniptt/guards';
import { CoreApiClient } from 'twenty-client-sdk/core';
import { defineLogicFunction, type DatabaseEventPayload, type ObjectRecordCreateEvent } from 'twenty-sdk/define';
import { ON_RESEND_SEGMENT_CREATED_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER } from 'src/modules/resend/constants/universal-identifiers';
import type { ResendSegmentRecord } from 'src/modules/resend/shared/types/resend-segment-record';
import { findOrCreateResendSegment } from 'src/modules/resend/sync/utils/find-or-create-resend-segment';
import { getResendClient } from 'src/modules/resend/shared/utils/get-resend-client';
import { ON_RESEND_SEGMENT_CREATED_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER } from '@modules/resend/constants/universal-identifiers';
import type { ResendSegmentRecord } from '@modules/resend/shared/types/resend-segment-record';
import { findOrCreateResendSegment } from '@modules/resend/sync/utils/find-or-create-resend-segment';
import { getResendClient } from '@modules/resend/shared/utils/get-resend-client';
type SegmentCreateEvent = DatabaseEventPayload<
ObjectRecordCreateEvent<ResendSegmentRecord>
@@ -26,10 +26,10 @@ const handler = async (
return { skipped: true, reason: 'no name on record' };
}
const resend = getResendClient();
const resendClient = getResendClient();
const client = new CoreApiClient();
const resendId = await findOrCreateResendSegment(resend, client, name);
const resendId = await findOrCreateResendSegment(resendClient, client, name);
await client.mutation({
updateResendSegment: {

Some files were not shown because too many files have changed in this diff Show More