Twenty for twenty app (#19804)

## Twenty for Twenty: Resend module

Introduces `packages/twenty-apps/internal/twenty-for-twenty`, the
official internal Twenty app, with a first module integrating
[Resend](https://resend.com).

### Breakdown

**Resend module** (`src/modules/resend/`)
- Two app variables: `RESEND_API_KEY` and `RESEND_WEBHOOK_SECRET`.
- **Objects**: `resendContact`, `resendSegment`, `resendTemplate`,
`resendBroadcast`, `resendEmail`, with relations between them and to
standard `person`.
- **Inbound sync (Resend → Twenty)**:
- Cron-driven logic function `sync-resend-data` (every 5 min) pulling
all entities through paginated, rate-limit-aware utilities
(`sync-contacts`, `sync-segments`, `sync-templates`, `sync-broadcasts`,
`sync-emails`).
- Webhook endpoint (`resend-webhook`) verifying signatures and handling
`contact.*` and `email.*` events in real time.
- `find-or-create-person` auto-links Resend contacts to Twenty people by
email.
- **Outbound sync (Twenty → Resend)**: DB-event logic functions for
`contact.created/updated/deleted` and `segment.created/deleted`, with a
`lastSyncedFromResend` field for loop prevention.
- **UI**: views, page layouts, navigation menu items, and front
components (`HtmlPreview`, `RecordHtmlViewer`) to preview email/template
HTML in record pages; `sync-resend-data` command exposed as a front
component.

### Setup

See the new README for install steps, webhook configuration, and local
testing with the Resend CLI.
This commit is contained in:
Raphaël Bosi 2026-04-17 19:29:09 +02:00 committed by GitHub
parent ce2a0bfbe5
commit 619ea13649
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
106 changed files with 9922 additions and 0 deletions

View file

@ -0,0 +1,42 @@
name: CD
on:
push:
branches:
- main
pull_request:
types: [labeled]
permissions:
contents: read
env:
TWENTY_DEPLOY_URL: http://localhost:3000
concurrency:
group: cd-${{ github.ref }}
cancel-in-progress: true
jobs:
deploy-and-install:
if: >-
github.event_name == 'push' ||
(github.event_name == 'pull_request' && github.event.label.name == 'deploy')
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
- name: Deploy
uses: twentyhq/twenty/.github/actions/deploy-twenty-app@main
with:
api-url: ${{ env.TWENTY_DEPLOY_URL }}
api-key: ${{ secrets.TWENTY_DEPLOY_API_KEY }}
- name: Install
uses: twentyhq/twenty/.github/actions/install-twenty-app@main
with:
api-url: ${{ env.TWENTY_DEPLOY_URL }}
api-key: ${{ secrets.TWENTY_DEPLOY_API_KEY }}

View file

@ -0,0 +1,48 @@
name: CI
on:
push:
branches:
- main
pull_request: {}
permissions:
contents: read
env:
TWENTY_VERSION: latest
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true
jobs:
test:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Spawn Twenty test instance
id: twenty
uses: twentyhq/twenty/.github/actions/spawn-twenty-app-dev-test@main
with:
twenty-version: ${{ env.TWENTY_VERSION }}
- name: Enable Corepack
run: corepack enable
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version-file: '.nvmrc'
cache: yarn
- name: Install dependencies
run: yarn install --immutable
- name: Run integration tests
run: yarn test
env:
TWENTY_API_URL: ${{ steps.twenty.outputs.server-url }}
TWENTY_API_KEY: ${{ steps.twenty.outputs.api-key }}

View file

@ -0,0 +1,38 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn
# codegen
generated
# testing
/coverage
# dev
/dist/
.twenty
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files (can opt-in for committing if needed)
.env*
# typescript
*.tsbuildinfo
*.d.ts

View file

@ -0,0 +1 @@
24.5.0

View file

@ -0,0 +1,19 @@
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"plugins": ["typescript"],
"categories": {
"correctness": "off"
},
"ignorePatterns": ["node_modules", "dist"],
"rules": {
"no-unused-vars": "off",
"typescript/no-unused-vars": [
"warn",
{
"argsIgnorePattern": "^_"
}
],
"typescript/no-explicit-any": "off"
}
}

View file

@ -0,0 +1,14 @@
## Base documentation
- Documentation: https://docs.twenty.com/developers/extend/apps/getting-started
- Rich app example: https://github.com/twentyhq/twenty/tree/main/packages/twenty-apps/examples/postcard
## UUID requirement
- All generated UUIDs must be valid UUID v4.
## Common Pitfalls
- Creating an object without an index view associated. Unless this is a technical object, user will need to visualize it.
- Creating a view without a navigationMenuItem associated. This will make the view available on the left sidebar.
- Creating a front-end component that has a scroll instead of being responsive to its fixed widget height and width, unless it is specifically meant to be used in a canvas tab.

View file

@ -0,0 +1,14 @@
## Base documentation
- Documentation: https://docs.twenty.com/developers/extend/apps/getting-started
- Rich app example: https://github.com/twentyhq/twenty/tree/main/packages/twenty-apps/examples/postcard
## UUID requirement
- All generated UUIDs must be valid UUID v4.
## Common Pitfalls
- Creating an object without an index view associated. Unless this is a technical object, user will need to visualize it.
- Creating a view without a navigationMenuItem associated. This will make the view available on the left sidebar.
- Creating a front-end component that has a scroll instead of being responsive to its fixed widget height and width, unless it is specifically meant to be used in a canvas tab.

View file

@ -0,0 +1,14 @@
## Base documentation
- Documentation: https://docs.twenty.com/developers/extend/apps/getting-started
- Rich app example: https://github.com/twentyhq/twenty/tree/main/packages/twenty-apps/examples/postcard
## UUID requirement
- All generated UUIDs must be valid UUID v4.
## Common Pitfalls
- Creating an object without an index view associated. Unless this is a technical object, user will need to visualize it.
- Creating a view without a navigationMenuItem associated. This will make the view available on the left sidebar.
- Creating a front-end component that has a scroll instead of being responsive to its fixed widget height and width, unless it is specifically meant to be used in a canvas tab.

View file

@ -0,0 +1,122 @@
This is a [Twenty](https://twenty.com) application bootstrapped with [`create-twenty-app`](https://www.npmjs.com/package/create-twenty-app).
## Overview
**Twenty for Twenty** is the official internal Twenty app. It is organized into modules, each integrating a third-party service with Twenty.
### Resend module (`src/modules/resend/`)
Two-way sync between Twenty and the [Resend](https://resend.com) email platform. The module syncs contacts, segments, templates, broadcasts, and emails.
**Inbound (Resend -> Twenty):**
- A cron job runs every 5 minutes to pull all entities from the Resend API
- A webhook endpoint receives real-time events for contacts and emails
**Outbound (Twenty -> Resend):**
- Database event triggers push contact and segment changes back to Resend when records are created, updated, or deleted in Twenty
## Getting Started
### 1. Install and run the app
```bash
yarn twenty dev
```
This registers the app with your local Twenty instance at `http://localhost:3000/settings/applications`.
### 2. Configure app variables
In Twenty, go to **Settings > Applications > Twenty for Twenty** and set:
- **RESEND_API_KEY** -- Your Resend API key. Create one at https://resend.com/api-keys (full access recommended).
- **RESEND_WEBHOOK_SECRET** -- The signing secret for verifying inbound webhooks (see "Webhook setup" below).
### 3. Webhook setup
The app exposes an HTTP endpoint at `/s/webhook/resend` that receives Resend webhook events. To connect it:
1. Go to https://resend.com/webhooks
2. Click **Add webhook**
3. Set the **Endpoint URL** to your Twenty server's public URL + `/s/webhook/resend` (e.g. `https://your-domain.com/s/webhook/resend`)
4. Set **Events types** to **All Events**
5. Click **Add**
6. Copy the **signing secret** Resend displays and paste it into the `RESEND_WEBHOOK_SECRET` app variable in Twenty
The webhook handles:
- **Contact events** (`contact.created`, `contact.updated`, `contact.deleted`) -- upserts/deletes Resend contact records in Twenty
- **Email events** (`email.sent`, `email.delivered`, `email.bounced`, `email.opened`, `email.clicked`, etc.) -- updates delivery status on Resend email records in real-time
- **Domain events** -- logged and skipped (no domain object in the app yet)
### 4. Testing webhooks locally
Install the [Resend CLI](https://resend.com/docs/resend-cli):
```bash
brew install resend/cli/resend
```
Or via npm if Homebrew has issues:
```bash
npm install -g resend-cli
```
Authenticate:
```bash
resend login
```
Start the webhook listener with forwarding to your local Twenty server:
```bash
resend webhooks listen --forward-to http://localhost:3000/s/webhook/resend
```
The CLI will:
1. Create a public tunnel automatically
2. Register a temporary webhook in Resend pointing to that tunnel
3. Forward incoming events (with Svix signature headers) to your local Twenty server
4. Display events in the terminal as they arrive
5. Clean up the temporary webhook when you press Ctrl+C
To trigger test events, create or update a contact in the [Resend dashboard](https://resend.com/contacts), or send a test email.
## Sync behavior
### Inbound sync
| Source | Mechanism | Entities |
|---|---|---|
| Cron (every 5 min) | Polls Resend API, upserts into Twenty | Contacts, segments, templates, broadcasts, emails |
| Webhook (real-time) | Receives Resend events via HTTP | Contacts, emails |
### Outbound sync
| Twenty action | Resend API call |
|---|---|
| Create contact | `contacts.create()` -- writes `resendId` back to Twenty |
| Update contact (name, email, unsubscribed) | `contacts.update()` |
| Delete contact | `contacts.remove()` |
| Create segment | `segments.create()` -- writes `resendId` back to Twenty |
| Delete segment | `segments.remove()` |
### Loop prevention
A `lastSyncedFromResend` field on contact, segment, and email records tracks when data came from Resend. Outbound triggers skip processing when this field is part of the update, preventing infinite echo loops between inbound and outbound sync.
## Commands
Run `yarn twenty help` to list all available commands.
## Learn More
- [Twenty Apps documentation](https://docs.twenty.com/developers/extend/apps/getting-started)
- [twenty-sdk CLI reference](https://www.npmjs.com/package/twenty-sdk)
- [Resend API documentation](https://resend.com/docs)
- [Discord](https://discord.gg/cx5n4Jzs57)

View file

@ -0,0 +1,36 @@
{
"name": "twenty-for-twenty",
"version": "0.1.0",
"license": "MIT",
"engines": {
"node": "^24.5.0",
"npm": "please-use-yarn",
"yarn": ">=4.0.2"
},
"keywords": [],
"packageManager": "yarn@4.9.2",
"scripts": {
"twenty": "twenty",
"lint": "oxlint -c .oxlintrc.json .",
"lint:fix": "oxlint --fix -c .oxlintrc.json .",
"test": "vitest run",
"test:watch": "vitest",
"test:unit": "vitest run --config vitest.unit.config.ts",
"test:unit:watch": "vitest --config vitest.unit.config.ts"
},
"dependencies": {
"resend": "^6.12.0",
"twenty-client-sdk": "1.22.0",
"twenty-sdk": "1.22.0"
},
"devDependencies": {
"@types/node": "^24.7.2",
"@types/react": "^19.0.0",
"oxlint": "^0.16.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"typescript": "^5.9.3",
"vite-tsconfig-paths": "^4.2.1",
"vitest": "^3.1.1"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 504 B

View file

@ -0,0 +1,87 @@
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { appDevOnce, appUninstall } from 'twenty-sdk/cli';
const APP_PATH = process.cwd();
const CONFIG_DIR = path.join(os.homedir(), '.twenty');
function validateEnv(): { apiUrl: string; apiKey: string } {
const apiUrl = process.env.TWENTY_API_URL;
const apiKey = process.env.TWENTY_API_KEY;
if (!apiUrl || !apiKey) {
throw new Error(
'TWENTY_API_URL and TWENTY_API_KEY must be set.\n' +
'Start a local server: yarn twenty server start\n' +
'Or set them in vitest env config.',
);
}
return { apiUrl, apiKey };
}
async function checkServer(apiUrl: string) {
let response: Response;
try {
response = await fetch(`${apiUrl}/healthz`);
} catch {
throw new Error(
`Twenty server is not reachable at ${apiUrl}. ` +
'Make sure the server is running before executing integration tests.',
);
}
if (!response.ok) {
throw new Error(`Server at ${apiUrl} returned ${response.status}`);
}
}
function writeConfig(apiUrl: string, apiKey: string) {
const payload = JSON.stringify(
{
remotes: {
local: { apiUrl, apiKey, accessToken: apiKey },
},
defaultRemote: 'local',
},
null,
2,
);
fs.mkdirSync(CONFIG_DIR, { recursive: true });
fs.writeFileSync(path.join(CONFIG_DIR, 'config.test.json'), payload);
}
export async function setup() {
const { apiUrl, apiKey } = validateEnv();
await checkServer(apiUrl);
writeConfig(apiUrl, apiKey);
await appUninstall({ appPath: APP_PATH }).catch(() => {});
const result = await appDevOnce({
appPath: APP_PATH,
onProgress: (message: string) => console.log(`[dev] ${message}`),
});
if (!result.success) {
throw new Error(
`Dev sync failed: ${result.error?.message ?? 'Unknown error'}`,
);
}
}
export async function teardown() {
const uninstallResult = await appUninstall({ appPath: APP_PATH });
if (!uninstallResult.success) {
console.warn(
`App uninstall failed: ${uninstallResult.error?.message ?? 'Unknown error'}`,
);
}
}

View file

@ -0,0 +1,46 @@
import { CoreApiClient } from 'twenty-client-sdk/core';
import { MetadataApiClient } from 'twenty-client-sdk/metadata';
import { APPLICATION_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
import { describe, expect, it } from 'vitest';
describe('App installation', () => {
it('should find the installed app in the applications list', async () => {
const client = new MetadataApiClient();
const result = await client.query({
findManyApplications: {
id: true,
name: true,
universalIdentifier: true,
},
});
const app = result.findManyApplications.find(
(a: { universalIdentifier: string }) =>
a.universalIdentifier === APPLICATION_UNIVERSAL_IDENTIFIER,
);
expect(app).toBeDefined();
});
});
describe('CoreApiClient', () => {
it('should support CRUD on standard objects', async () => {
const client = new CoreApiClient();
const created = await client.mutation({
createNote: {
__args: { data: { title: 'Integration test note' } },
id: true,
},
});
expect(created.createNote.id).toBeDefined();
await client.mutation({
destroyNote: {
__args: { id: created.createNote.id },
id: true,
},
});
});
});

View file

@ -0,0 +1,32 @@
import { defineApplication } from 'twenty-sdk';
import {
APP_DESCRIPTION,
APP_DISPLAY_NAME,
APPLICATION_UNIVERSAL_IDENTIFIER,
DEFAULT_ROLE_UNIVERSAL_IDENTIFIER,
} from 'src/constants/universal-identifiers';
import {
RESEND_API_KEY_UNIVERSAL_IDENTIFIER,
RESEND_WEBHOOK_SECRET_UNIVERSAL_IDENTIFIER,
} from 'src/modules/resend/constants/universal-identifiers';
export default defineApplication({
universalIdentifier: APPLICATION_UNIVERSAL_IDENTIFIER,
displayName: APP_DISPLAY_NAME,
description: APP_DESCRIPTION,
logoUrl: 'public/resend-icon-black.svg',
applicationVariables: {
RESEND_API_KEY: {
universalIdentifier: RESEND_API_KEY_UNIVERSAL_IDENTIFIER,
description: 'API key for the Resend service',
isSecret: true,
},
RESEND_WEBHOOK_SECRET: {
universalIdentifier: RESEND_WEBHOOK_SECRET_UNIVERSAL_IDENTIFIER,
description: 'Signing secret for verifying Resend webhook payloads',
isSecret: true,
},
},
defaultRoleUniversalIdentifier: DEFAULT_ROLE_UNIVERSAL_IDENTIFIER,
});

View file

@ -0,0 +1,5 @@
export const APP_DISPLAY_NAME = 'Twenty for Twenty';
export const APP_DESCRIPTION =
'The official Twenty internal app modules for Resend and more';
export const APPLICATION_UNIVERSAL_IDENTIFIER = '9426c4a3-7da4-4f61-bdb7-01e1276478b8';
export const DEFAULT_ROLE_UNIVERSAL_IDENTIFIER = 'b115921d-7ca3-4a1f-94e3-7119174fef78';

View file

@ -0,0 +1,16 @@
import { defineRole } from 'twenty-sdk';
import {
APP_DISPLAY_NAME,
DEFAULT_ROLE_UNIVERSAL_IDENTIFIER,
} from 'src/constants/universal-identifiers';
export default defineRole({
universalIdentifier: DEFAULT_ROLE_UNIVERSAL_IDENTIFIER,
label: `${APP_DISPLAY_NAME} default function role`,
description: `${APP_DISPLAY_NAME} default function role`,
canReadAllObjectRecords: true,
canUpdateAllObjectRecords: true,
canSoftDeleteAllObjectRecords: true,
canDestroyAllObjectRecords: false,
});

View file

@ -0,0 +1,329 @@
// App-level
export const RESEND_API_KEY_UNIVERSAL_IDENTIFIER =
'e5828892-33d5-4532-b796-551df48a07c0';
export const RESEND_WEBHOOK_SECRET_UNIVERSAL_IDENTIFIER =
'b291b241-bd84-4661-8e79-3dc7a63371dd';
// Logic functions
export const SYNC_RESEND_DATA_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER =
'7a3c841f-509e-46f0-b2f1-fb942b716ee3';
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 =
'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 =
'd5e5b6e1-e0d9-45f0-b3d9-6b96417e4ed0';
// Commands
export const SYNC_RESEND_DATA_COMMAND_UNIVERSAL_IDENTIFIER =
'70c1c7bc-b3f1-491a-90c8-2616facc7a9c';
// 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';
// Objects
export const RESEND_EMAIL_OBJECT_UNIVERSAL_IDENTIFIER =
'd59c00df-9715-46b1-bacd-580681435cef';
export const RESEND_SEGMENT_OBJECT_UNIVERSAL_IDENTIFIER =
'f06283b0-c7f9-4267-86de-e489f816cca1';
export const RESEND_CONTACT_OBJECT_UNIVERSAL_IDENTIFIER =
'cb91a26f-131b-4db4-916b-7a308fcc29d7';
export const RESEND_TEMPLATE_OBJECT_UNIVERSAL_IDENTIFIER =
'85ddb31f-0d1c-4619-bbaf-3d208c1b9fea';
export const RESEND_BROADCAST_OBJECT_UNIVERSAL_IDENTIFIER =
'bebc114f-a8f5-455d-8c6f-e33f20f66967';
// Object fields - Resend email
export const SUBJECT_FIELD_UNIVERSAL_IDENTIFIER =
'9e52c08b-d619-445a-b70b-121dc7676ff3';
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 =
'3db64fe7-99e9-4d44-81be-e0a55d32b211';
export const REPLY_TO_ADDRESSES_FIELD_UNIVERSAL_IDENTIFIER =
'acbea701-ca16-4db2-960f-7d8c8493e42d';
export const LAST_EVENT_FIELD_UNIVERSAL_IDENTIFIER =
'c24331f3-43da-4143-9763-53ae01205300';
export const EMAIL_RESEND_ID_FIELD_UNIVERSAL_IDENTIFIER =
'374e4e3a-dc13-4159-813e-e5da789a97f0';
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';
// Object fields - Resend segment
export const SEGMENT_NAME_FIELD_UNIVERSAL_IDENTIFIER =
'92aad4eb-bfc7-4c3d-aa0a-fad5cf9cbda1';
export const SEGMENT_RESEND_ID_FIELD_UNIVERSAL_IDENTIFIER =
'5bce2a58-9eae-4903-b599-3a602e759373';
export const SEGMENT_CREATED_AT_FIELD_UNIVERSAL_IDENTIFIER =
'4757fafa-3032-4f69-ba09-86d968239a8f';
export const SEGMENT_LAST_SYNCED_FROM_RESEND_FIELD_UNIVERSAL_IDENTIFIER =
'8001f093-09c0-4a22-b7a3-9b1dcee47ce2';
// Object fields - Resend contact
export const CONTACT_EMAIL_FIELD_UNIVERSAL_IDENTIFIER =
'62ce21e5-a015-4f26-8c7a-3c141b6d7064';
export const NAME_FIELD_UNIVERSAL_IDENTIFIER =
'e0fb9280-4588-4c2b-8996-bbd4c1d33f54';
export const UNSUBSCRIBED_FIELD_UNIVERSAL_IDENTIFIER =
'41403b6f-b91e-4eb6-8875-8b5c21b0a6d3';
export const CONTACT_RESEND_ID_FIELD_UNIVERSAL_IDENTIFIER =
'c84c8703-33e1-4acb-8a7f-1d62647166d5';
export const CONTACT_CREATED_AT_FIELD_UNIVERSAL_IDENTIFIER =
'040bf210-36cf-49cb-8d83-0da9b864c900';
export const CONTACT_LAST_SYNCED_FROM_RESEND_FIELD_UNIVERSAL_IDENTIFIER =
'd1874e6a-db94-4308-afc6-aeb4dc3a9eb2';
// Object fields - Resend template
export const TEMPLATE_NAME_FIELD_UNIVERSAL_IDENTIFIER =
'a045319f-483d-4625-86cd-653111dc8ac3';
export const TEMPLATE_ALIAS_FIELD_UNIVERSAL_IDENTIFIER =
'850a9d4a-df38-4af0-ba69-aaef090cafef';
export const TEMPLATE_STATUS_FIELD_UNIVERSAL_IDENTIFIER =
'f98b84c0-c8ef-4f9e-a72a-a53e2ef60fea';
export const TEMPLATE_FROM_ADDRESS_FIELD_UNIVERSAL_IDENTIFIER =
'33a80d6c-e6ee-490e-b5ac-87b557bcdf50';
export const TEMPLATE_SUBJECT_FIELD_UNIVERSAL_IDENTIFIER =
'3aff5a64-49fa-450c-b85d-4d4f210f6433';
export const TEMPLATE_REPLY_TO_FIELD_UNIVERSAL_IDENTIFIER =
'b05c1a87-4cea-4e01-8e6a-eff5929de149';
export const TEMPLATE_HTML_FIELD_UNIVERSAL_IDENTIFIER =
'9ced95c8-345d-438d-97dc-f52b99f4a9c3';
export const TEMPLATE_TEXT_FIELD_UNIVERSAL_IDENTIFIER =
'd7614d4c-ed00-4961-8765-7c8b0c9a320b';
export const TEMPLATE_RESEND_ID_FIELD_UNIVERSAL_IDENTIFIER =
'3883be35-ab0e-4bed-bc59-131683b9a0b2';
export const TEMPLATE_CREATED_AT_FIELD_UNIVERSAL_IDENTIFIER =
'72939761-7a57-4448-997a-68da6b4f60db';
export const TEMPLATE_UPDATED_AT_FIELD_UNIVERSAL_IDENTIFIER =
'cfe6a645-ee89-4117-8ace-fb9623e726cc';
export const TEMPLATE_PUBLISHED_AT_FIELD_UNIVERSAL_IDENTIFIER =
'669a3ebb-d60b-41a3-81e9-53ab8b18f2b1';
// Object fields - Resend broadcast
export const BROADCAST_NAME_FIELD_UNIVERSAL_IDENTIFIER =
'e3222f79-751d-4c9e-b33d-c5f2e6ee8304';
export const BROADCAST_SUBJECT_FIELD_UNIVERSAL_IDENTIFIER =
'51025e6b-375a-45a0-89eb-773314702073';
export const BROADCAST_FROM_ADDRESS_FIELD_UNIVERSAL_IDENTIFIER =
'fa189da3-3443-4984-b637-7fae9b30e2c5';
export const BROADCAST_REPLY_TO_FIELD_UNIVERSAL_IDENTIFIER =
'8674b4e8-1881-4001-a9df-8e9258b59d50';
export const PREVIEW_TEXT_FIELD_UNIVERSAL_IDENTIFIER =
'b8a008aa-1a44-4edb-b472-f392af635867';
export const BROADCAST_STATUS_FIELD_UNIVERSAL_IDENTIFIER =
'394135a4-6488-4ec3-97bc-d0514921ded8';
export const BROADCAST_RESEND_ID_FIELD_UNIVERSAL_IDENTIFIER =
'ee40a63f-6d32-496a-9f4d-1abfba386909';
export const BROADCAST_CREATED_AT_FIELD_UNIVERSAL_IDENTIFIER =
'01855a84-450b-4de9-b172-e51555724370';
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';
// Relation fields
export const SEGMENT_ON_RESEND_BROADCAST_FIELD_UNIVERSAL_IDENTIFIER =
'8b335824-b19d-486e-b865-5761c795c971';
export const RESEND_BROADCASTS_ON_SEGMENT_FIELD_UNIVERSAL_IDENTIFIER =
'784601f0-e892-4164-90db-6903ab062c7e';
export const PERSON_ON_RESEND_CONTACT_FIELD_UNIVERSAL_IDENTIFIER =
'90353fa9-10fd-4cc8-b7b0-ef9a5d17ff8e';
export const RESEND_CONTACTS_ON_PERSON_FIELD_UNIVERSAL_IDENTIFIER =
'134222c5-7760-4fd1-b89d-8a956f3068c5';
export const SEGMENT_ON_RESEND_CONTACT_FIELD_UNIVERSAL_IDENTIFIER =
'5fbd9063-fb4e-4584-820b-27918b6f95f0';
export const BROADCAST_ON_RESEND_EMAIL_FIELD_UNIVERSAL_IDENTIFIER =
'4cd54c84-1e3e-4d3f-a35d-b32a6e8e1137';
export const RESEND_EMAILS_ON_BROADCAST_FIELD_UNIVERSAL_IDENTIFIER =
'0b346627-4797-4cff-8a7c-1dc8f4580d6f';
export const CONTACT_ON_RESEND_EMAIL_FIELD_UNIVERSAL_IDENTIFIER =
'46ffbf1c-0c25-4995-a209-291626b6fd2d';
export const RESEND_EMAILS_ON_PERSON_FIELD_UNIVERSAL_IDENTIFIER =
'c6142228-e0e4-4143-9942-df4fce3ef231';
export const RESEND_CONTACTS_ON_SEGMENT_FIELD_UNIVERSAL_IDENTIFIER =
'ba14727c-19eb-4b08-844c-88bf33b8267d';
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';
// Views
export const RESEND_BROADCAST_VIEW_UNIVERSAL_IDENTIFIER =
'9875e352-4dd7-4296-9291-5de5864594b8';
export const RESEND_TEMPLATE_VIEW_UNIVERSAL_IDENTIFIER =
'c4a00e17-cec7-44f6-85c6-5826a0db8923';
export const RESEND_SEGMENT_VIEW_UNIVERSAL_IDENTIFIER =
'30683084-d0d4-4d25-bb5f-c6bcff9fc92a';
export const RESEND_CONTACT_VIEW_UNIVERSAL_IDENTIFIER =
'3d710924-5f47-4ac7-ba5e-28d3be9ee004';
export const RESEND_EMAIL_VIEW_UNIVERSAL_IDENTIFIER =
'43571c5b-f71d-47bf-95b3-8741b2201315';
// View fields - Resend broadcast view
export const RESEND_BROADCAST_VIEW_NAME_FIELD_UNIVERSAL_IDENTIFIER =
'7d2bd63b-d609-441e-97b7-8d72e1f64fcb';
export const RESEND_BROADCAST_VIEW_SUBJECT_FIELD_UNIVERSAL_IDENTIFIER =
'0cb2b32f-a7ca-4c6e-91af-705be37416dd';
export const RESEND_BROADCAST_VIEW_FROM_ADDRESS_FIELD_UNIVERSAL_IDENTIFIER =
'24261a77-9ddd-4fee-bcef-10308b6e438e';
export const RESEND_BROADCAST_VIEW_STATUS_FIELD_UNIVERSAL_IDENTIFIER =
'ba957060-3879-4812-98ac-0199732c79d9';
export const RESEND_BROADCAST_VIEW_SENT_AT_FIELD_UNIVERSAL_IDENTIFIER =
'7e78132b-7b33-468e-962d-d7bb1b3025d4';
export const RESEND_BROADCAST_VIEW_CREATED_AT_FIELD_UNIVERSAL_IDENTIFIER =
'fdcf945c-5d0f-42f2-94e6-765d5216bd2f';
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';
// View fields - Resend template view
export const RESEND_TEMPLATE_VIEW_NAME_FIELD_UNIVERSAL_IDENTIFIER =
'01464797-cd0c-4686-b4fd-8e7f9d2e81d4';
export const RESEND_TEMPLATE_VIEW_SUBJECT_FIELD_UNIVERSAL_IDENTIFIER =
'c9c823c4-27f2-4805-a6ce-e22e97c5ac16';
export const RESEND_TEMPLATE_VIEW_FROM_ADDRESS_FIELD_UNIVERSAL_IDENTIFIER =
'36a274f8-4273-4b16-8a66-99c652ca308d';
export const RESEND_TEMPLATE_VIEW_STATUS_FIELD_UNIVERSAL_IDENTIFIER =
'0474cc6b-3e28-4fb6-b0ba-3b22004eb9c4';
export const RESEND_TEMPLATE_VIEW_UPDATED_AT_FIELD_UNIVERSAL_IDENTIFIER =
'6bc34358-17b2-40c6-a0bc-3320ac972736';
export const RESEND_TEMPLATE_VIEW_CREATED_AT_FIELD_UNIVERSAL_IDENTIFIER =
'e3e90309-715c-4962-95d3-ddc46124fc93';
// View fields - Resend segment view
export const RESEND_SEGMENT_VIEW_NAME_FIELD_UNIVERSAL_IDENTIFIER =
'dc878169-b670-4aff-90b4-869849ec063e';
export const RESEND_SEGMENT_VIEW_CREATED_AT_FIELD_UNIVERSAL_IDENTIFIER =
'4898ae92-b94e-4bbf-850d-76011613fd64';
export const RESEND_SEGMENT_VIEW_CONTACTS_FIELD_UNIVERSAL_IDENTIFIER =
'cd223c54-d968-4830-b84f-ebefe8595842';
export const RESEND_SEGMENT_VIEW_BROADCASTS_FIELD_UNIVERSAL_IDENTIFIER =
'0adacbc6-f355-49bb-9350-b7202cf28b8c';
// View fields - Resend contact view
export const RESEND_CONTACT_VIEW_NAME_FIELD_UNIVERSAL_IDENTIFIER =
'f589ef0c-0f8b-4895-8d84-bd3b743f925f';
export const RESEND_CONTACT_VIEW_EMAIL_FIELD_UNIVERSAL_IDENTIFIER =
'3af5988f-de6b-46de-996b-439bd5acd51c';
export const RESEND_CONTACT_VIEW_UNSUBSCRIBED_FIELD_UNIVERSAL_IDENTIFIER =
'efa38cf1-afcf-461a-9780-6d916e02256b';
export const RESEND_CONTACT_VIEW_CREATED_AT_FIELD_UNIVERSAL_IDENTIFIER =
'a48545bb-8cf1-4836-82fa-d0c7bc314a1b';
export const RESEND_CONTACT_VIEW_PERSON_FIELD_UNIVERSAL_IDENTIFIER =
'9fc1ab4b-fc2d-4c33-9de5-3cff7b0ea5ec';
export const RESEND_CONTACT_VIEW_SEGMENT_FIELD_UNIVERSAL_IDENTIFIER =
'54949f7e-0454-45a5-9e30-8f16f75adeaa';
export const RESEND_CONTACT_VIEW_EMAILS_FIELD_UNIVERSAL_IDENTIFIER =
'fbbe207d-dbda-4bda-935c-a6fa9ae30645';
// View fields - Resend email view
export const RESEND_EMAIL_VIEW_SUBJECT_FIELD_UNIVERSAL_IDENTIFIER =
'3a3ee801-6dfa-43b4-ba22-5c079a2d238d';
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 =
'94e9b3ce-c858-4b15-96ca-69fedf834853';
export const RESEND_EMAIL_VIEW_CREATED_AT_FIELD_UNIVERSAL_IDENTIFIER =
'61dd7f9f-ce22-4fb4-aeee-f36154211cf8';
export const RESEND_EMAIL_VIEW_PERSON_FIELD_UNIVERSAL_IDENTIFIER =
'5b6cf4a7-f11f-49d4-bc05-8f1d6e0ad72a';
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';
// Navigation menu items
export const RESEND_FOLDER_NAVIGATION_MENU_ITEM_UNIVERSAL_IDENTIFIER =
'd87574b8-f09b-4963-bfa0-9e4afd433035';
export const RESEND_TEMPLATE_NAVIGATION_MENU_ITEM_UNIVERSAL_IDENTIFIER =
'c56546f1-e4f0-4d03-a480-d152e4b8b427';
export const RESEND_SEGMENT_NAVIGATION_MENU_ITEM_UNIVERSAL_IDENTIFIER =
'6aa7a107-3c7a-4099-9f8e-b1fa21e6f612';
export const RESEND_EMAIL_NAVIGATION_MENU_ITEM_UNIVERSAL_IDENTIFIER =
'9d99110c-68ba-41c0-bd95-9b829ab84974';
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';
// Page layouts - Resend template record page
export const RESEND_TEMPLATE_RECORD_PAGE_LAYOUT_UNIVERSAL_IDENTIFIER =
'466cb8b7-70ec-4bb8-802d-6a6bdab4f647';
export const RESEND_TEMPLATE_RECORD_PAGE_HOME_TAB_UNIVERSAL_IDENTIFIER =
'f8106373-a1a2-4bb8-86b5-0faf8ed52953';
export const RESEND_TEMPLATE_RECORD_PAGE_HOME_FIELDS_WIDGET_UNIVERSAL_IDENTIFIER =
'fa3ad75b-b700-4450-8961-c4395a10ba9f';
export const RESEND_TEMPLATE_RECORD_PAGE_PREVIEW_TAB_UNIVERSAL_IDENTIFIER =
'56206825-2f86-40be-b311-f5ebc91e016b';
export const RESEND_TEMPLATE_RECORD_PAGE_PREVIEW_WIDGET_UNIVERSAL_IDENTIFIER =
'534b14b0-8ed1-414b-8a83-b631955c2058';
export const RESEND_TEMPLATE_RECORD_PAGE_TIMELINE_TAB_UNIVERSAL_IDENTIFIER =
'8b62586a-f976-4bae-8ec0-696369e8ec0f';
export const RESEND_TEMPLATE_RECORD_PAGE_TIMELINE_WIDGET_UNIVERSAL_IDENTIFIER =
'4099d844-7104-4fc0-8cb0-b1e0f88d9d33';
export const RESEND_TEMPLATE_RECORD_PAGE_TASKS_TAB_UNIVERSAL_IDENTIFIER =
'5dc2596a-28c8-4cd1-8500-6648b576f64a';
export const RESEND_TEMPLATE_RECORD_PAGE_TASKS_WIDGET_UNIVERSAL_IDENTIFIER =
'2fe143b4-aa7e-43d3-8a4e-becbe94e96a6';
export const RESEND_TEMPLATE_RECORD_PAGE_NOTES_TAB_UNIVERSAL_IDENTIFIER =
'd82f101a-93fc-44c6-afd9-26dd7a1ba99a';
export const RESEND_TEMPLATE_RECORD_PAGE_NOTES_WIDGET_UNIVERSAL_IDENTIFIER =
'57c3e4e2-d898-40f3-9273-2dbc07746dba';
export const RESEND_TEMPLATE_RECORD_PAGE_FILES_TAB_UNIVERSAL_IDENTIFIER =
'f017f93c-c611-46ca-9bef-6c5038cf9609';
export const RESEND_TEMPLATE_RECORD_PAGE_FILES_WIDGET_UNIVERSAL_IDENTIFIER =
'3059a217-5322-4744-9ed0-4757591bba1c';
// Page layouts - Resend email record page
export const RESEND_EMAIL_RECORD_PAGE_LAYOUT_UNIVERSAL_IDENTIFIER =
'e481afa9-f100-4d88-959d-d4b3518583a2';
export const RESEND_EMAIL_RECORD_PAGE_HOME_TAB_UNIVERSAL_IDENTIFIER =
'7273a427-5920-42c9-8f50-82bebf01f2bd';
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';
export const RESEND_EMAIL_RECORD_PAGE_PREVIEW_WIDGET_UNIVERSAL_IDENTIFIER =
'10bedce3-3e4f-4639-8500-e2035241f364';
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 =
'e45c80c7-d5b7-4109-aa5a-ab534d7c4ca2';
export const RESEND_EMAIL_RECORD_PAGE_TASKS_TAB_UNIVERSAL_IDENTIFIER =
'bce4141e-d115-43ea-94f0-5c45d0ab38b2';
export const RESEND_EMAIL_RECORD_PAGE_TASKS_WIDGET_UNIVERSAL_IDENTIFIER =
'9eab60e5-e305-482d-aec0-ab928a20c855';
export const RESEND_EMAIL_RECORD_PAGE_NOTES_TAB_UNIVERSAL_IDENTIFIER =
'45ada37c-1f26-4552-9726-308638304ddc';
export const RESEND_EMAIL_RECORD_PAGE_NOTES_WIDGET_UNIVERSAL_IDENTIFIER =
'a4edbea4-a5c3-4316-b6a7-c46fc97d36cd';
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';

View file

@ -0,0 +1,39 @@
import { isNonEmptyString } from '@sniptt/guards';
import { isDefined } from 'twenty-shared/utils';
type HtmlPreviewProps = {
html: string | null | undefined;
};
export const HtmlPreview = ({ html }: HtmlPreviewProps) => {
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 (
<iframe
srcDoc={html}
sandbox=""
title="Email HTML preview"
style={{
width: '100%',
height: '100%',
border: 'none',
}}
/>
);
};

View file

@ -0,0 +1,55 @@
import { isDefined } from 'twenty-shared/utils';
import { HtmlPreview } from 'src/modules/resend/html-viewer/components/HtmlPreview';
import { useRecordHtml } from 'src/modules/resend/html-viewer/hooks/useRecordHtml';
type RecordHtmlViewerProps = {
objectName: string;
loadingText: string;
};
export const RecordHtmlViewer = ({
objectName,
loadingText,
}: RecordHtmlViewerProps) => {
const { html, loading, error } = useRecordHtml(objectName);
if (loading) {
return (
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
height: '100%',
color: '#999',
fontFamily: 'sans-serif',
fontSize: '14px',
}}
>
{loadingText}
</div>
);
}
if (isDefined(error)) {
return (
<div
style={{
padding: '16px',
color: '#999',
fontFamily: 'sans-serif',
fontSize: '13px',
}}
>
<div>{error}</div>
</div>
);
}
return (
<div style={{ width: '100%', height: '100%' }}>
<HtmlPreview html={html} />
</div>
);
};

View file

@ -0,0 +1,15 @@
import { defineFrontComponent } from 'twenty-sdk';
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,
});

View file

@ -0,0 +1,19 @@
import { defineFrontComponent } from 'twenty-sdk';
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';
const TemplateHtmlViewer = () => (
<RecordHtmlViewer
objectName="resendTemplate"
loadingText="Loading template..."
/>
);
export default defineFrontComponent({
universalIdentifier:
TEMPLATE_HTML_VIEWER_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER,
name: 'Template HTML Viewer',
description: 'Renders the HTML body of a Resend email template',
component: TemplateHtmlViewer,
});

View file

@ -0,0 +1,59 @@
import { useEffect, useState } from 'react';
import { useRecordId } from 'twenty-sdk';
import { CoreApiClient } from 'twenty-client-sdk/core';
import { isDefined } from 'twenty-shared/utils';
type RecordHtmlState = {
html: string | null;
loading: boolean;
error: string | null;
};
export const useRecordHtml = (objectName: string): RecordHtmlState => {
const recordId = useRecordId();
const [state, setState] = useState<RecordHtmlState>({
html: null,
loading: true,
error: null,
});
useEffect(() => {
if (!isDefined(recordId)) {
setState({ html: null, loading: false, error: 'No record ID' });
return;
}
setState({ html: null, loading: true, error: null });
new CoreApiClient()
.query({
[objectName]: {
__args: { filter: { id: { eq: recordId } } },
htmlBody: true,
},
})
.then((result) => {
const record = (result as Record<string, unknown>)[objectName] as
| { htmlBody?: string | null }
| undefined;
setState(
isDefined(record)
? { html: record.htmlBody ?? null, loading: false, error: null }
: { html: null, loading: false, error: 'Record not found' },
);
})
.catch((fetchError: unknown) => {
setState({
html: null,
loading: false,
error:
fetchError instanceof Error
? fetchError.message
: String(fetchError),
});
});
}, [recordId, objectName]);
return state;
};

View file

@ -0,0 +1,93 @@
import { MetadataApiClient } from 'twenty-client-sdk/metadata';
import {
Command,
defineFrontComponent,
enqueueSnackbar,
updateProgress,
} from 'twenty-sdk';
import { isDefined } from 'twenty-shared/utils';
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';
const execute = async () => {
await updateProgress(0.1);
const metadataClient = new MetadataApiClient();
const { findManyLogicFunctions } = await metadataClient.query({
findManyLogicFunctions: {
id: true,
universalIdentifier: true,
},
});
const syncFunction = findManyLogicFunctions.find(
(fn) =>
fn.universalIdentifier ===
SYNC_RESEND_DATA_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER,
);
if (!isDefined(syncFunction)) {
throw new Error('Sync logic function not found');
}
await updateProgress(0.3);
const { executeOneLogicFunction } = await metadataClient.mutation({
executeOneLogicFunction: {
__args: {
input: {
id: syncFunction.id,
payload: {} as Record<string, never>,
},
},
status: true,
error: true,
},
});
if (executeOneLogicFunction.status !== 'SUCCESS') {
const rawMessage =
typeof executeOneLogicFunction.error?.errorMessage === 'string'
? executeOneLogicFunction.error.errorMessage
: 'Sync logic function execution failed';
const isRateLimit =
rawMessage.toLowerCase().includes('rate_limit') ||
rawMessage.toLowerCase().includes('rate limit');
throw new Error(
isRateLimit
? 'Sync failed: Resend API rate limit exceeded. Please try again later.'
: `Sync failed: ${rawMessage}`,
);
}
await updateProgress(1);
await enqueueSnackbar({
message: 'Resend data sync completed',
variant: 'success',
});
};
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',
isHeadless: true,
component: SyncResendData,
command: {
universalIdentifier: SYNC_RESEND_DATA_COMMAND_UNIVERSAL_IDENTIFIER,
label: 'Sync Resend data',
icon: 'IconRefresh',
isPinned: false,
availabilityType: 'GLOBAL',
},
});

View file

@ -0,0 +1,24 @@
import {
BROADCAST_ON_RESEND_EMAIL_FIELD_UNIVERSAL_IDENTIFIER,
RESEND_BROADCAST_OBJECT_UNIVERSAL_IDENTIFIER,
RESEND_EMAILS_ON_BROADCAST_FIELD_UNIVERSAL_IDENTIFIER,
RESEND_EMAIL_OBJECT_UNIVERSAL_IDENTIFIER,
} from 'src/modules/resend/constants/universal-identifiers';
import { defineField, FieldType, RelationType } from 'twenty-sdk';
export default defineField({
universalIdentifier: BROADCAST_ON_RESEND_EMAIL_FIELD_UNIVERSAL_IDENTIFIER,
objectUniversalIdentifier: RESEND_EMAIL_OBJECT_UNIVERSAL_IDENTIFIER,
type: FieldType.RELATION,
name: 'broadcast',
label: 'Broadcast',
relationTargetObjectMetadataUniversalIdentifier:
RESEND_BROADCAST_OBJECT_UNIVERSAL_IDENTIFIER,
relationTargetFieldMetadataUniversalIdentifier:
RESEND_EMAILS_ON_BROADCAST_FIELD_UNIVERSAL_IDENTIFIER,
universalSettings: {
relationType: RelationType.MANY_TO_ONE,
joinColumnName: 'broadcastId',
},
icon: 'IconSpeakerphone',
});

View file

@ -0,0 +1,24 @@
import {
CONTACT_ON_RESEND_EMAIL_FIELD_UNIVERSAL_IDENTIFIER,
RESEND_CONTACT_OBJECT_UNIVERSAL_IDENTIFIER,
RESEND_EMAILS_ON_CONTACT_FIELD_UNIVERSAL_IDENTIFIER,
RESEND_EMAIL_OBJECT_UNIVERSAL_IDENTIFIER,
} from 'src/modules/resend/constants/universal-identifiers';
import { defineField, FieldType, RelationType } from 'twenty-sdk';
export default defineField({
universalIdentifier: CONTACT_ON_RESEND_EMAIL_FIELD_UNIVERSAL_IDENTIFIER,
objectUniversalIdentifier: RESEND_EMAIL_OBJECT_UNIVERSAL_IDENTIFIER,
type: FieldType.RELATION,
name: 'contact',
label: 'Contact',
relationTargetObjectMetadataUniversalIdentifier:
RESEND_CONTACT_OBJECT_UNIVERSAL_IDENTIFIER,
relationTargetFieldMetadataUniversalIdentifier:
RESEND_EMAILS_ON_CONTACT_FIELD_UNIVERSAL_IDENTIFIER,
universalSettings: {
relationType: RelationType.MANY_TO_ONE,
joinColumnName: 'contactId',
},
icon: 'IconAddressBook',
});

View file

@ -0,0 +1,28 @@
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';
import {
defineField,
FieldType,
RelationType,
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS,
} from 'twenty-sdk';
export default defineField({
universalIdentifier: PERSON_ON_RESEND_CONTACT_FIELD_UNIVERSAL_IDENTIFIER,
objectUniversalIdentifier: RESEND_CONTACT_OBJECT_UNIVERSAL_IDENTIFIER,
type: FieldType.RELATION,
name: 'person',
label: 'Person',
relationTargetObjectMetadataUniversalIdentifier:
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.person.universalIdentifier,
relationTargetFieldMetadataUniversalIdentifier:
RESEND_CONTACTS_ON_PERSON_FIELD_UNIVERSAL_IDENTIFIER,
universalSettings: {
relationType: RelationType.MANY_TO_ONE,
joinColumnName: 'personId',
},
icon: 'IconUser',
});

View file

@ -0,0 +1,28 @@
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';
import {
defineField,
FieldType,
RelationType,
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS,
} from 'twenty-sdk';
export default defineField({
universalIdentifier: PERSON_ON_RESEND_EMAIL_FIELD_UNIVERSAL_IDENTIFIER,
objectUniversalIdentifier: RESEND_EMAIL_OBJECT_UNIVERSAL_IDENTIFIER,
type: FieldType.RELATION,
name: 'person',
label: 'Person',
relationTargetObjectMetadataUniversalIdentifier:
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.person.universalIdentifier,
relationTargetFieldMetadataUniversalIdentifier:
RESEND_EMAILS_ON_PERSON_FIELD_UNIVERSAL_IDENTIFIER,
universalSettings: {
relationType: RelationType.MANY_TO_ONE,
joinColumnName: 'personId',
},
icon: 'IconUser',
});

View file

@ -0,0 +1,23 @@
import {
RESEND_BROADCASTS_ON_SEGMENT_FIELD_UNIVERSAL_IDENTIFIER,
RESEND_BROADCAST_OBJECT_UNIVERSAL_IDENTIFIER,
RESEND_SEGMENT_OBJECT_UNIVERSAL_IDENTIFIER,
SEGMENT_ON_RESEND_BROADCAST_FIELD_UNIVERSAL_IDENTIFIER,
} from 'src/modules/resend/constants/universal-identifiers';
import { defineField, FieldType, RelationType } from 'twenty-sdk';
export default defineField({
universalIdentifier: RESEND_BROADCASTS_ON_SEGMENT_FIELD_UNIVERSAL_IDENTIFIER,
objectUniversalIdentifier: RESEND_SEGMENT_OBJECT_UNIVERSAL_IDENTIFIER,
type: FieldType.RELATION,
name: 'resendBroadcasts',
label: 'Broadcasts',
relationTargetObjectMetadataUniversalIdentifier:
RESEND_BROADCAST_OBJECT_UNIVERSAL_IDENTIFIER,
relationTargetFieldMetadataUniversalIdentifier:
SEGMENT_ON_RESEND_BROADCAST_FIELD_UNIVERSAL_IDENTIFIER,
universalSettings: {
relationType: RelationType.ONE_TO_MANY,
},
icon: 'IconSpeakerphone',
});

View file

@ -0,0 +1,28 @@
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';
import {
defineField,
FieldType,
RelationType,
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS,
} from 'twenty-sdk';
export default defineField({
universalIdentifier: RESEND_CONTACTS_ON_PERSON_FIELD_UNIVERSAL_IDENTIFIER,
objectUniversalIdentifier:
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.person.universalIdentifier,
type: FieldType.RELATION,
name: 'resendContacts',
label: 'Resend Contacts',
relationTargetObjectMetadataUniversalIdentifier:
RESEND_CONTACT_OBJECT_UNIVERSAL_IDENTIFIER,
relationTargetFieldMetadataUniversalIdentifier:
PERSON_ON_RESEND_CONTACT_FIELD_UNIVERSAL_IDENTIFIER,
universalSettings: {
relationType: RelationType.ONE_TO_MANY,
},
icon: 'IconAddressBook',
});

View file

@ -0,0 +1,23 @@
import {
RESEND_CONTACTS_ON_SEGMENT_FIELD_UNIVERSAL_IDENTIFIER,
RESEND_CONTACT_OBJECT_UNIVERSAL_IDENTIFIER,
RESEND_SEGMENT_OBJECT_UNIVERSAL_IDENTIFIER,
SEGMENT_ON_RESEND_CONTACT_FIELD_UNIVERSAL_IDENTIFIER,
} from 'src/modules/resend/constants/universal-identifiers';
import { defineField, FieldType, RelationType } from 'twenty-sdk';
export default defineField({
universalIdentifier: RESEND_CONTACTS_ON_SEGMENT_FIELD_UNIVERSAL_IDENTIFIER,
objectUniversalIdentifier: RESEND_SEGMENT_OBJECT_UNIVERSAL_IDENTIFIER,
type: FieldType.RELATION,
name: 'resendContacts',
label: 'Contacts',
relationTargetObjectMetadataUniversalIdentifier:
RESEND_CONTACT_OBJECT_UNIVERSAL_IDENTIFIER,
relationTargetFieldMetadataUniversalIdentifier:
SEGMENT_ON_RESEND_CONTACT_FIELD_UNIVERSAL_IDENTIFIER,
universalSettings: {
relationType: RelationType.ONE_TO_MANY,
},
icon: 'IconAddressBook',
});

View file

@ -0,0 +1,23 @@
import {
BROADCAST_ON_RESEND_EMAIL_FIELD_UNIVERSAL_IDENTIFIER,
RESEND_BROADCAST_OBJECT_UNIVERSAL_IDENTIFIER,
RESEND_EMAILS_ON_BROADCAST_FIELD_UNIVERSAL_IDENTIFIER,
RESEND_EMAIL_OBJECT_UNIVERSAL_IDENTIFIER,
} from 'src/modules/resend/constants/universal-identifiers';
import { defineField, FieldType, RelationType } from 'twenty-sdk';
export default defineField({
universalIdentifier: RESEND_EMAILS_ON_BROADCAST_FIELD_UNIVERSAL_IDENTIFIER,
objectUniversalIdentifier: RESEND_BROADCAST_OBJECT_UNIVERSAL_IDENTIFIER,
type: FieldType.RELATION,
name: 'resendEmails',
label: 'Emails',
relationTargetObjectMetadataUniversalIdentifier:
RESEND_EMAIL_OBJECT_UNIVERSAL_IDENTIFIER,
relationTargetFieldMetadataUniversalIdentifier:
BROADCAST_ON_RESEND_EMAIL_FIELD_UNIVERSAL_IDENTIFIER,
universalSettings: {
relationType: RelationType.ONE_TO_MANY,
},
icon: 'IconMail',
});

View file

@ -0,0 +1,23 @@
import {
CONTACT_ON_RESEND_EMAIL_FIELD_UNIVERSAL_IDENTIFIER,
RESEND_CONTACT_OBJECT_UNIVERSAL_IDENTIFIER,
RESEND_EMAILS_ON_CONTACT_FIELD_UNIVERSAL_IDENTIFIER,
RESEND_EMAIL_OBJECT_UNIVERSAL_IDENTIFIER,
} from 'src/modules/resend/constants/universal-identifiers';
import { defineField, FieldType, RelationType } from 'twenty-sdk';
export default defineField({
universalIdentifier: RESEND_EMAILS_ON_CONTACT_FIELD_UNIVERSAL_IDENTIFIER,
objectUniversalIdentifier: RESEND_CONTACT_OBJECT_UNIVERSAL_IDENTIFIER,
type: FieldType.RELATION,
name: 'resendEmails',
label: 'Emails',
relationTargetObjectMetadataUniversalIdentifier:
RESEND_EMAIL_OBJECT_UNIVERSAL_IDENTIFIER,
relationTargetFieldMetadataUniversalIdentifier:
CONTACT_ON_RESEND_EMAIL_FIELD_UNIVERSAL_IDENTIFIER,
universalSettings: {
relationType: RelationType.ONE_TO_MANY,
},
icon: 'IconMail',
});

View file

@ -0,0 +1,28 @@
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';
import {
defineField,
FieldType,
RelationType,
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS,
} from 'twenty-sdk';
export default defineField({
universalIdentifier: RESEND_EMAILS_ON_PERSON_FIELD_UNIVERSAL_IDENTIFIER,
objectUniversalIdentifier:
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.person.universalIdentifier,
type: FieldType.RELATION,
name: 'resendEmails',
label: 'Resend Emails',
relationTargetObjectMetadataUniversalIdentifier:
RESEND_EMAIL_OBJECT_UNIVERSAL_IDENTIFIER,
relationTargetFieldMetadataUniversalIdentifier:
PERSON_ON_RESEND_EMAIL_FIELD_UNIVERSAL_IDENTIFIER,
universalSettings: {
relationType: RelationType.ONE_TO_MANY,
},
icon: 'IconMail',
});

View file

@ -0,0 +1,24 @@
import {
RESEND_BROADCASTS_ON_SEGMENT_FIELD_UNIVERSAL_IDENTIFIER,
RESEND_BROADCAST_OBJECT_UNIVERSAL_IDENTIFIER,
RESEND_SEGMENT_OBJECT_UNIVERSAL_IDENTIFIER,
SEGMENT_ON_RESEND_BROADCAST_FIELD_UNIVERSAL_IDENTIFIER,
} from 'src/modules/resend/constants/universal-identifiers';
import { defineField, FieldType, RelationType } from 'twenty-sdk';
export default defineField({
universalIdentifier: SEGMENT_ON_RESEND_BROADCAST_FIELD_UNIVERSAL_IDENTIFIER,
objectUniversalIdentifier: RESEND_BROADCAST_OBJECT_UNIVERSAL_IDENTIFIER,
type: FieldType.RELATION,
name: 'segment',
label: 'Segment',
relationTargetObjectMetadataUniversalIdentifier:
RESEND_SEGMENT_OBJECT_UNIVERSAL_IDENTIFIER,
relationTargetFieldMetadataUniversalIdentifier:
RESEND_BROADCASTS_ON_SEGMENT_FIELD_UNIVERSAL_IDENTIFIER,
universalSettings: {
relationType: RelationType.MANY_TO_ONE,
joinColumnName: 'segmentId',
},
icon: 'IconUsersGroup',
});

View file

@ -0,0 +1,24 @@
import {
RESEND_CONTACTS_ON_SEGMENT_FIELD_UNIVERSAL_IDENTIFIER,
RESEND_CONTACT_OBJECT_UNIVERSAL_IDENTIFIER,
RESEND_SEGMENT_OBJECT_UNIVERSAL_IDENTIFIER,
SEGMENT_ON_RESEND_CONTACT_FIELD_UNIVERSAL_IDENTIFIER,
} from 'src/modules/resend/constants/universal-identifiers';
import { defineField, FieldType, RelationType } from 'twenty-sdk';
export default defineField({
universalIdentifier: SEGMENT_ON_RESEND_CONTACT_FIELD_UNIVERSAL_IDENTIFIER,
objectUniversalIdentifier: RESEND_CONTACT_OBJECT_UNIVERSAL_IDENTIFIER,
type: FieldType.RELATION,
name: 'segment',
label: 'Segment',
relationTargetObjectMetadataUniversalIdentifier:
RESEND_SEGMENT_OBJECT_UNIVERSAL_IDENTIFIER,
relationTargetFieldMetadataUniversalIdentifier:
RESEND_CONTACTS_ON_SEGMENT_FIELD_UNIVERSAL_IDENTIFIER,
universalSettings: {
relationType: RelationType.MANY_TO_ONE,
joinColumnName: 'segmentId',
},
icon: 'IconUsersGroup',
});

View file

@ -0,0 +1,19 @@
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';
import { defineNavigationMenuItem } from 'twenty-sdk';
import { NavigationMenuItemType } from 'twenty-shared/types';
export default defineNavigationMenuItem({
universalIdentifier:
RESEND_BROADCAST_NAVIGATION_MENU_ITEM_UNIVERSAL_IDENTIFIER,
name: 'Broadcasts',
icon: 'IconSpeakerphone',
position: 2,
type: NavigationMenuItemType.VIEW,
viewUniversalIdentifier: RESEND_BROADCAST_VIEW_UNIVERSAL_IDENTIFIER,
folderUniversalIdentifier:
RESEND_FOLDER_NAVIGATION_MENU_ITEM_UNIVERSAL_IDENTIFIER,
});

View file

@ -0,0 +1,18 @@
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';
import { defineNavigationMenuItem } from 'twenty-sdk';
import { NavigationMenuItemType } from 'twenty-shared/types';
export default defineNavigationMenuItem({
universalIdentifier: RESEND_CONTACT_NAVIGATION_MENU_ITEM_UNIVERSAL_IDENTIFIER,
name: 'Contacts',
icon: 'IconAddressBook',
position: 1,
type: NavigationMenuItemType.VIEW,
viewUniversalIdentifier: RESEND_CONTACT_VIEW_UNIVERSAL_IDENTIFIER,
folderUniversalIdentifier:
RESEND_FOLDER_NAVIGATION_MENU_ITEM_UNIVERSAL_IDENTIFIER,
});

View file

@ -0,0 +1,18 @@
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';
import { defineNavigationMenuItem } from 'twenty-sdk';
import { NavigationMenuItemType } from 'twenty-shared/types';
export default defineNavigationMenuItem({
universalIdentifier: RESEND_EMAIL_NAVIGATION_MENU_ITEM_UNIVERSAL_IDENTIFIER,
name: 'Emails',
icon: 'IconMail',
position: 0,
type: NavigationMenuItemType.VIEW,
viewUniversalIdentifier: RESEND_EMAIL_VIEW_UNIVERSAL_IDENTIFIER,
folderUniversalIdentifier:
RESEND_FOLDER_NAVIGATION_MENU_ITEM_UNIVERSAL_IDENTIFIER,
});

View file

@ -0,0 +1,11 @@
import { RESEND_FOLDER_NAVIGATION_MENU_ITEM_UNIVERSAL_IDENTIFIER } from 'src/modules/resend/constants/universal-identifiers';
import { defineNavigationMenuItem } from 'twenty-sdk';
import { NavigationMenuItemType } from 'twenty-shared/types';
export default defineNavigationMenuItem({
universalIdentifier: RESEND_FOLDER_NAVIGATION_MENU_ITEM_UNIVERSAL_IDENTIFIER,
name: 'Resend',
icon: 'IconMail',
position: 0,
type: NavigationMenuItemType.FOLDER,
});

View file

@ -0,0 +1,19 @@
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';
import { defineNavigationMenuItem } from 'twenty-sdk';
import { NavigationMenuItemType } from 'twenty-shared/types';
export default defineNavigationMenuItem({
universalIdentifier:
RESEND_SEGMENT_NAVIGATION_MENU_ITEM_UNIVERSAL_IDENTIFIER,
name: 'Segments',
icon: 'IconUsersGroup',
position: 4,
type: NavigationMenuItemType.VIEW,
viewUniversalIdentifier: RESEND_SEGMENT_VIEW_UNIVERSAL_IDENTIFIER,
folderUniversalIdentifier:
RESEND_FOLDER_NAVIGATION_MENU_ITEM_UNIVERSAL_IDENTIFIER,
});

View file

@ -0,0 +1,19 @@
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';
import { defineNavigationMenuItem } from 'twenty-sdk';
import { NavigationMenuItemType } from 'twenty-shared/types';
export default defineNavigationMenuItem({
universalIdentifier:
RESEND_TEMPLATE_NAVIGATION_MENU_ITEM_UNIVERSAL_IDENTIFIER,
name: 'Templates',
icon: 'IconTemplate',
position: 3,
type: NavigationMenuItemType.VIEW,
viewUniversalIdentifier: RESEND_TEMPLATE_VIEW_UNIVERSAL_IDENTIFIER,
folderUniversalIdentifier:
RESEND_FOLDER_NAVIGATION_MENU_ITEM_UNIVERSAL_IDENTIFIER,
});

View file

@ -0,0 +1,138 @@
import {
BROADCAST_CREATED_AT_FIELD_UNIVERSAL_IDENTIFIER,
BROADCAST_FROM_ADDRESS_FIELD_UNIVERSAL_IDENTIFIER,
BROADCAST_NAME_FIELD_UNIVERSAL_IDENTIFIER,
BROADCAST_REPLY_TO_FIELD_UNIVERSAL_IDENTIFIER,
BROADCAST_RESEND_ID_FIELD_UNIVERSAL_IDENTIFIER,
BROADCAST_SCHEDULED_AT_FIELD_UNIVERSAL_IDENTIFIER,
BROADCAST_SENT_AT_FIELD_UNIVERSAL_IDENTIFIER,
BROADCAST_STATUS_FIELD_UNIVERSAL_IDENTIFIER,
BROADCAST_SUBJECT_FIELD_UNIVERSAL_IDENTIFIER,
PREVIEW_TEXT_FIELD_UNIVERSAL_IDENTIFIER,
RESEND_BROADCAST_OBJECT_UNIVERSAL_IDENTIFIER,
} from 'src/modules/resend/constants/universal-identifiers';
import { defineObject, FieldType } from 'twenty-sdk';
export default defineObject({
universalIdentifier: RESEND_BROADCAST_OBJECT_UNIVERSAL_IDENTIFIER,
nameSingular: 'resendBroadcast',
namePlural: 'resendBroadcasts',
labelSingular: 'Resend broadcast',
labelPlural: 'Resend broadcasts',
description: 'A broadcast email campaign from Resend',
icon: 'IconSpeakerphone',
labelIdentifierFieldMetadataUniversalIdentifier:
BROADCAST_NAME_FIELD_UNIVERSAL_IDENTIFIER,
fields: [
{
universalIdentifier: BROADCAST_NAME_FIELD_UNIVERSAL_IDENTIFIER,
type: FieldType.TEXT,
name: 'name',
label: 'Name',
description: 'Name of the broadcast',
icon: 'IconAbc',
},
{
universalIdentifier: BROADCAST_SUBJECT_FIELD_UNIVERSAL_IDENTIFIER,
type: FieldType.TEXT,
name: 'subject',
label: 'Subject',
description: 'Email subject line',
icon: 'IconMail',
},
{
universalIdentifier: BROADCAST_FROM_ADDRESS_FIELD_UNIVERSAL_IDENTIFIER,
type: FieldType.EMAILS,
name: 'fromAddress',
label: 'From',
description: 'Sender email address',
icon: 'IconMailForward',
},
{
universalIdentifier: BROADCAST_REPLY_TO_FIELD_UNIVERSAL_IDENTIFIER,
type: FieldType.EMAILS,
name: 'replyTo',
label: 'Reply to',
description: 'Reply-to email address',
icon: 'IconMailReply',
},
{
universalIdentifier: PREVIEW_TEXT_FIELD_UNIVERSAL_IDENTIFIER,
type: FieldType.TEXT,
name: 'previewText',
label: 'Preview text',
description: 'Preview text shown in email clients',
icon: 'IconEye',
},
{
universalIdentifier: BROADCAST_STATUS_FIELD_UNIVERSAL_IDENTIFIER,
type: FieldType.SELECT,
name: 'status',
label: 'Status',
description: 'Current broadcast status',
icon: 'IconStatusChange',
options: [
{
id: 'c090f598-1e30-4cc2-9686-7413b926760e',
value: 'DRAFT',
label: 'Draft',
position: 0,
color: 'gray',
},
{
id: 'd8428e6a-8a2b-4307-89d8-f8095f8f425c',
value: 'QUEUED',
label: 'Queued',
position: 1,
color: 'blue',
},
{
id: '13c38a05-511b-4bf3-86c1-824d084107c8',
value: 'SENDING',
label: 'Sending',
position: 2,
color: 'yellow',
},
{
id: '79150897-a5c0-4695-9a59-cd40db44b066',
value: 'SENT',
label: 'Sent',
position: 3,
color: 'green',
},
],
},
{
universalIdentifier: BROADCAST_RESEND_ID_FIELD_UNIVERSAL_IDENTIFIER,
type: FieldType.TEXT,
name: 'resendId',
label: 'Resend ID',
description: 'Resend broadcast identifier',
icon: 'IconHash',
},
{
universalIdentifier: BROADCAST_CREATED_AT_FIELD_UNIVERSAL_IDENTIFIER,
type: FieldType.DATE_TIME,
name: 'createdAt',
label: 'Created at',
description: 'When the broadcast was created',
icon: 'IconCalendar',
},
{
universalIdentifier: BROADCAST_SCHEDULED_AT_FIELD_UNIVERSAL_IDENTIFIER,
type: FieldType.DATE_TIME,
name: 'scheduledAt',
label: 'Scheduled at',
description: 'When the broadcast is scheduled to be sent',
icon: 'IconCalendarEvent',
},
{
universalIdentifier: BROADCAST_SENT_AT_FIELD_UNIVERSAL_IDENTIFIER,
type: FieldType.DATE_TIME,
name: 'sentAt',
label: 'Sent at',
description: 'When the broadcast was sent',
icon: 'IconSend',
},
],
});

View file

@ -0,0 +1,73 @@
import {
CONTACT_CREATED_AT_FIELD_UNIVERSAL_IDENTIFIER,
CONTACT_EMAIL_FIELD_UNIVERSAL_IDENTIFIER,
CONTACT_LAST_SYNCED_FROM_RESEND_FIELD_UNIVERSAL_IDENTIFIER,
CONTACT_RESEND_ID_FIELD_UNIVERSAL_IDENTIFIER,
NAME_FIELD_UNIVERSAL_IDENTIFIER,
RESEND_CONTACT_OBJECT_UNIVERSAL_IDENTIFIER,
UNSUBSCRIBED_FIELD_UNIVERSAL_IDENTIFIER,
} from 'src/modules/resend/constants/universal-identifiers';
import { defineObject, FieldType } from 'twenty-sdk';
export default defineObject({
universalIdentifier: RESEND_CONTACT_OBJECT_UNIVERSAL_IDENTIFIER,
nameSingular: 'resendContact',
namePlural: 'resendContacts',
labelSingular: 'Resend contact',
labelPlural: 'Resend contacts',
description: 'A contact from Resend',
icon: 'IconAddressBook',
labelIdentifierFieldMetadataUniversalIdentifier:
NAME_FIELD_UNIVERSAL_IDENTIFIER,
fields: [
{
universalIdentifier: CONTACT_EMAIL_FIELD_UNIVERSAL_IDENTIFIER,
type: FieldType.EMAILS,
name: 'email',
label: 'Email',
description: 'Contact email address',
icon: 'IconMail',
},
{
universalIdentifier: NAME_FIELD_UNIVERSAL_IDENTIFIER,
type: FieldType.FULL_NAME,
name: 'name',
label: 'Name',
description: 'Name of the contact',
icon: 'IconUser',
},
{
universalIdentifier: UNSUBSCRIBED_FIELD_UNIVERSAL_IDENTIFIER,
type: FieldType.BOOLEAN,
name: 'unsubscribed',
label: 'Unsubscribed',
description: 'Whether the contact has unsubscribed',
icon: 'IconMailOff',
},
{
universalIdentifier: CONTACT_RESEND_ID_FIELD_UNIVERSAL_IDENTIFIER,
type: FieldType.TEXT,
name: 'resendId',
label: 'Resend ID',
description: 'Resend contact identifier',
icon: 'IconHash',
},
{
universalIdentifier: CONTACT_CREATED_AT_FIELD_UNIVERSAL_IDENTIFIER,
type: FieldType.DATE_TIME,
name: 'createdAt',
label: 'Created at',
description: 'When the contact was created',
icon: 'IconCalendar',
},
{
universalIdentifier:
CONTACT_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',
},
],
});

View file

@ -0,0 +1,238 @@
import {
BCC_ADDRESSES_FIELD_UNIVERSAL_IDENTIFIER,
CC_ADDRESSES_FIELD_UNIVERSAL_IDENTIFIER,
EMAIL_CREATED_AT_FIELD_UNIVERSAL_IDENTIFIER,
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';
import { defineObject, FieldType } from 'twenty-sdk';
export default defineObject({
universalIdentifier: RESEND_EMAIL_OBJECT_UNIVERSAL_IDENTIFIER,
nameSingular: 'resendEmail',
namePlural: 'resendEmails',
labelSingular: 'Resend email',
labelPlural: 'Resend emails',
description: 'An email sent via Resend',
icon: 'IconMail',
labelIdentifierFieldMetadataUniversalIdentifier:
SUBJECT_FIELD_UNIVERSAL_IDENTIFIER,
fields: [
{
universalIdentifier: SUBJECT_FIELD_UNIVERSAL_IDENTIFIER,
type: FieldType.TEXT,
name: 'subject',
label: 'Subject',
description: 'Email subject line',
icon: 'IconAbc',
},
{
universalIdentifier: FROM_ADDRESS_FIELD_UNIVERSAL_IDENTIFIER,
type: FieldType.EMAILS,
name: 'fromAddress',
label: 'From',
description: 'Sender email address',
icon: 'IconMailForward',
},
{
universalIdentifier: TO_ADDRESSES_FIELD_UNIVERSAL_IDENTIFIER,
type: FieldType.EMAILS,
name: 'toAddresses',
label: 'To',
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,
name: 'ccAddresses',
label: 'CC',
description: 'Carbon copy email addresses',
icon: 'IconCopy',
},
{
universalIdentifier: BCC_ADDRESSES_FIELD_UNIVERSAL_IDENTIFIER,
type: FieldType.EMAILS,
name: 'bccAddresses',
label: 'BCC',
description: 'Blind carbon copy email addresses',
icon: 'IconEyeOff',
},
{
universalIdentifier: REPLY_TO_ADDRESSES_FIELD_UNIVERSAL_IDENTIFIER,
type: FieldType.EMAILS,
name: 'replyToAddresses',
label: 'Reply to',
description: 'Reply-to email addresses',
icon: 'IconMailReply',
},
{
universalIdentifier: LAST_EVENT_FIELD_UNIVERSAL_IDENTIFIER,
type: FieldType.SELECT,
name: 'lastEvent',
label: 'Last event',
description: 'Most recent delivery status',
icon: 'IconStatusChange',
options: [
{
id: '1b69e2c9-81f0-4f14-8361-dda018c1c343',
value: 'SENT',
label: 'Sent',
position: 0,
color: 'blue',
},
{
id: '9964e796-5b4a-4631-a0bd-7ab1848ae207',
value: 'DELIVERED',
label: 'Delivered',
position: 1,
color: 'green',
},
{
id: 'e4ae61fe-2ea8-4ee3-8bb5-0c8f5c6e8081',
value: 'DELIVERY_DELAYED',
label: 'Delivery Delayed',
position: 2,
color: 'yellow',
},
{
id: 'ea6d5170-c522-4559-a51e-f3f81435c632',
value: 'COMPLAINED',
label: 'Complained',
position: 3,
color: 'orange',
},
{
id: 'eeafaa07-3b8a-4b89-b1d6-f4b113b0276f',
value: 'BOUNCED',
label: 'Bounced',
position: 4,
color: 'red',
},
{
id: 'fa6b6add-7b95-4bb5-8f5f-a532430ac201',
value: 'OPENED',
label: 'Opened',
position: 5,
color: 'turquoise',
},
{
id: 'c4529bad-d911-4f1f-aac3-620852a09eae',
value: 'CLICKED',
label: 'Clicked',
position: 6,
color: 'sky',
},
{
id: '05bd5800-eade-4500-92f6-97d91afabb54',
value: 'SCHEDULED',
label: 'Scheduled',
position: 7,
color: 'gray',
},
{
id: 'd71a6236-2e6e-4c4f-9cae-b806c2724302',
value: 'QUEUED',
label: 'Queued',
position: 8,
color: 'gray',
},
{
id: 'd6674c98-eb98-488a-860d-fbf8b4a073c9',
value: 'FAILED',
label: 'Failed',
position: 9,
color: 'red',
},
{
id: '9e042a8d-3f33-4915-938e-978065712065',
value: 'CANCELED',
label: 'Canceled',
position: 10,
color: 'gray',
},
{
id: '0c837074-d7ce-43b1-ac08-553e6ee10ece',
value: 'RECEIVED',
label: 'Received',
position: 11,
color: 'blue',
},
{
id: '9bfec759-a242-4d85-b124-c4a83949b8da',
value: 'SUPPRESSED',
label: 'Suppressed',
position: 12,
color: 'red',
},
],
},
{
universalIdentifier: EMAIL_RESEND_ID_FIELD_UNIVERSAL_IDENTIFIER,
type: FieldType.TEXT,
name: 'resendId',
label: 'Resend ID',
description: 'Resend email identifier',
icon: 'IconHash',
},
{
universalIdentifier: EMAIL_CREATED_AT_FIELD_UNIVERSAL_IDENTIFIER,
type: FieldType.DATE_TIME,
name: 'createdAt',
label: 'Created at',
description: 'When the email was created',
icon: 'IconCalendar',
},
{
universalIdentifier: SCHEDULED_AT_FIELD_UNIVERSAL_IDENTIFIER,
type: FieldType.DATE_TIME,
name: 'scheduledAt',
label: 'Scheduled at',
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,
type: FieldType.DATE_TIME,
name: 'lastSyncedFromResend',
label: 'Last synced from Resend',
description: 'Timestamp of last inbound sync (used to prevent echo loops)',
icon: 'IconClock',
},
],
});

View file

@ -0,0 +1,55 @@
import {
RESEND_SEGMENT_OBJECT_UNIVERSAL_IDENTIFIER,
SEGMENT_CREATED_AT_FIELD_UNIVERSAL_IDENTIFIER,
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';
import { defineObject, FieldType } from 'twenty-sdk';
export default defineObject({
universalIdentifier: RESEND_SEGMENT_OBJECT_UNIVERSAL_IDENTIFIER,
nameSingular: 'resendSegment',
namePlural: 'resendSegments',
labelSingular: 'Resend segment',
labelPlural: 'Resend segments',
description: 'A contact segment from Resend',
icon: 'IconUsersGroup',
labelIdentifierFieldMetadataUniversalIdentifier:
SEGMENT_NAME_FIELD_UNIVERSAL_IDENTIFIER,
fields: [
{
universalIdentifier: SEGMENT_NAME_FIELD_UNIVERSAL_IDENTIFIER,
type: FieldType.TEXT,
name: 'name',
label: 'Name',
description: 'Name of the segment',
icon: 'IconAbc',
},
{
universalIdentifier: SEGMENT_RESEND_ID_FIELD_UNIVERSAL_IDENTIFIER,
type: FieldType.TEXT,
name: 'resendId',
label: 'Resend ID',
description: 'Resend segment identifier',
icon: 'IconHash',
},
{
universalIdentifier: SEGMENT_CREATED_AT_FIELD_UNIVERSAL_IDENTIFIER,
type: FieldType.DATE_TIME,
name: 'createdAt',
label: 'Created at',
description: 'When the segment was created',
icon: 'IconCalendar',
},
{
universalIdentifier:
SEGMENT_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',
},
],
});

View file

@ -0,0 +1,142 @@
import {
RESEND_TEMPLATE_OBJECT_UNIVERSAL_IDENTIFIER,
TEMPLATE_ALIAS_FIELD_UNIVERSAL_IDENTIFIER,
TEMPLATE_CREATED_AT_FIELD_UNIVERSAL_IDENTIFIER,
TEMPLATE_FROM_ADDRESS_FIELD_UNIVERSAL_IDENTIFIER,
TEMPLATE_HTML_FIELD_UNIVERSAL_IDENTIFIER,
TEMPLATE_NAME_FIELD_UNIVERSAL_IDENTIFIER,
TEMPLATE_PUBLISHED_AT_FIELD_UNIVERSAL_IDENTIFIER,
TEMPLATE_REPLY_TO_FIELD_UNIVERSAL_IDENTIFIER,
TEMPLATE_RESEND_ID_FIELD_UNIVERSAL_IDENTIFIER,
TEMPLATE_STATUS_FIELD_UNIVERSAL_IDENTIFIER,
TEMPLATE_SUBJECT_FIELD_UNIVERSAL_IDENTIFIER,
TEMPLATE_TEXT_FIELD_UNIVERSAL_IDENTIFIER,
TEMPLATE_UPDATED_AT_FIELD_UNIVERSAL_IDENTIFIER,
} from 'src/modules/resend/constants/universal-identifiers';
import { defineObject, FieldType } from 'twenty-sdk';
export default defineObject({
universalIdentifier: RESEND_TEMPLATE_OBJECT_UNIVERSAL_IDENTIFIER,
nameSingular: 'resendTemplate',
namePlural: 'resendTemplates',
labelSingular: 'Resend template',
labelPlural: 'Resend templates',
description: 'An email template from Resend',
icon: 'IconTemplate',
labelIdentifierFieldMetadataUniversalIdentifier:
TEMPLATE_NAME_FIELD_UNIVERSAL_IDENTIFIER,
fields: [
{
universalIdentifier: TEMPLATE_NAME_FIELD_UNIVERSAL_IDENTIFIER,
type: FieldType.TEXT,
name: 'name',
label: 'Name',
description: 'Name of the template',
icon: 'IconAbc',
},
{
universalIdentifier: TEMPLATE_ALIAS_FIELD_UNIVERSAL_IDENTIFIER,
type: FieldType.TEXT,
name: 'alias',
label: 'Alias',
description: 'Alternative identifier for the template',
icon: 'IconTag',
},
{
universalIdentifier: TEMPLATE_STATUS_FIELD_UNIVERSAL_IDENTIFIER,
type: FieldType.SELECT,
name: 'status',
label: 'Status',
description: 'Publication status of the template',
icon: 'IconStatusChange',
options: [
{
id: '3047dfdd-5424-44bc-bead-f2e2a84f363f',
value: 'DRAFT',
label: 'Draft',
position: 0,
color: 'gray',
},
{
id: '146cf14d-e276-4477-86f5-4114de97bccb',
value: 'PUBLISHED',
label: 'Published',
position: 1,
color: 'green',
},
],
},
{
universalIdentifier: TEMPLATE_FROM_ADDRESS_FIELD_UNIVERSAL_IDENTIFIER,
type: FieldType.EMAILS,
name: 'fromAddress',
label: 'From',
description: 'Sender email address',
icon: 'IconMailForward',
},
{
universalIdentifier: TEMPLATE_SUBJECT_FIELD_UNIVERSAL_IDENTIFIER,
type: FieldType.TEXT,
name: 'subject',
label: 'Subject',
description: 'Email subject line',
icon: 'IconMail',
},
{
universalIdentifier: TEMPLATE_REPLY_TO_FIELD_UNIVERSAL_IDENTIFIER,
type: FieldType.EMAILS,
name: 'replyTo',
label: 'Reply to',
description: 'Reply-to email address',
icon: 'IconMailReply',
},
{
universalIdentifier: TEMPLATE_HTML_FIELD_UNIVERSAL_IDENTIFIER,
type: FieldType.TEXT,
name: 'htmlBody',
label: 'HTML body',
description: 'HTML content of the template',
icon: 'IconFileText',
},
{
universalIdentifier: TEMPLATE_TEXT_FIELD_UNIVERSAL_IDENTIFIER,
type: FieldType.TEXT,
name: 'textBody',
label: 'Text body',
description: 'Plain text content of the template',
icon: 'IconAlignLeft',
},
{
universalIdentifier: TEMPLATE_RESEND_ID_FIELD_UNIVERSAL_IDENTIFIER,
type: FieldType.TEXT,
name: 'resendId',
label: 'Resend ID',
description: 'Resend template identifier',
icon: 'IconHash',
},
{
universalIdentifier: TEMPLATE_CREATED_AT_FIELD_UNIVERSAL_IDENTIFIER,
type: FieldType.DATE_TIME,
name: 'createdAt',
label: 'Created at',
description: 'When the template was created',
icon: 'IconCalendar',
},
{
universalIdentifier: TEMPLATE_UPDATED_AT_FIELD_UNIVERSAL_IDENTIFIER,
type: FieldType.DATE_TIME,
name: 'resendUpdatedAt',
label: 'Resend updated at',
description: 'When the template was last updated in Resend',
icon: 'IconCalendarClock',
},
{
universalIdentifier: TEMPLATE_PUBLISHED_AT_FIELD_UNIVERSAL_IDENTIFIER,
type: FieldType.DATE_TIME,
name: 'publishedAt',
label: 'Published at',
description: 'When the template was published',
icon: 'IconCalendarEvent',
},
],
});

View file

@ -0,0 +1,142 @@
import {
EMAIL_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,
RESEND_EMAIL_RECORD_PAGE_HOME_FIELDS_WIDGET_UNIVERSAL_IDENTIFIER,
RESEND_EMAIL_RECORD_PAGE_HOME_TAB_UNIVERSAL_IDENTIFIER,
RESEND_EMAIL_RECORD_PAGE_LAYOUT_UNIVERSAL_IDENTIFIER,
RESEND_EMAIL_RECORD_PAGE_NOTES_TAB_UNIVERSAL_IDENTIFIER,
RESEND_EMAIL_RECORD_PAGE_NOTES_WIDGET_UNIVERSAL_IDENTIFIER,
RESEND_EMAIL_RECORD_PAGE_PREVIEW_TAB_UNIVERSAL_IDENTIFIER,
RESEND_EMAIL_RECORD_PAGE_PREVIEW_WIDGET_UNIVERSAL_IDENTIFIER,
RESEND_EMAIL_RECORD_PAGE_TASKS_TAB_UNIVERSAL_IDENTIFIER,
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';
import { definePageLayout, PageLayoutTabLayoutMode } from 'twenty-sdk';
export default definePageLayout({
universalIdentifier: RESEND_EMAIL_RECORD_PAGE_LAYOUT_UNIVERSAL_IDENTIFIER,
name: 'Resend Email Record Page',
type: 'RECORD_PAGE',
objectUniversalIdentifier: RESEND_EMAIL_OBJECT_UNIVERSAL_IDENTIFIER,
tabs: [
{
universalIdentifier: RESEND_EMAIL_RECORD_PAGE_HOME_TAB_UNIVERSAL_IDENTIFIER,
title: 'Home',
position: 50,
icon: 'IconHome',
layoutMode: PageLayoutTabLayoutMode.VERTICAL_LIST,
widgets: [
{
universalIdentifier:
RESEND_EMAIL_RECORD_PAGE_HOME_FIELDS_WIDGET_UNIVERSAL_IDENTIFIER,
title: 'Fields',
type: 'FIELDS',
configuration: {
configurationType: 'FIELDS',
},
},
],
},
{
universalIdentifier:
RESEND_EMAIL_RECORD_PAGE_PREVIEW_TAB_UNIVERSAL_IDENTIFIER,
title: 'Preview',
position: 75,
icon: 'IconEye',
layoutMode: PageLayoutTabLayoutMode.CANVAS,
widgets: [
{
universalIdentifier:
RESEND_EMAIL_RECORD_PAGE_PREVIEW_WIDGET_UNIVERSAL_IDENTIFIER,
title: 'Email Preview',
type: 'FRONT_COMPONENT',
configuration: {
configurationType: 'FRONT_COMPONENT',
frontComponentUniversalIdentifier:
EMAIL_HTML_VIEWER_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER,
},
},
],
},
{
universalIdentifier:
RESEND_EMAIL_RECORD_PAGE_TIMELINE_TAB_UNIVERSAL_IDENTIFIER,
title: 'Timeline',
position: 100,
icon: 'IconTimelineEvent',
layoutMode: PageLayoutTabLayoutMode.CANVAS,
widgets: [
{
universalIdentifier:
RESEND_EMAIL_RECORD_PAGE_TIMELINE_WIDGET_UNIVERSAL_IDENTIFIER,
title: 'Timeline',
type: 'TIMELINE',
configuration: {
configurationType: 'TIMELINE',
},
},
],
},
{
universalIdentifier:
RESEND_EMAIL_RECORD_PAGE_TASKS_TAB_UNIVERSAL_IDENTIFIER,
title: 'Tasks',
position: 200,
icon: 'IconCheckbox',
layoutMode: PageLayoutTabLayoutMode.CANVAS,
widgets: [
{
universalIdentifier:
RESEND_EMAIL_RECORD_PAGE_TASKS_WIDGET_UNIVERSAL_IDENTIFIER,
title: 'Tasks',
type: 'TASKS',
configuration: {
configurationType: 'TASKS',
},
},
],
},
{
universalIdentifier:
RESEND_EMAIL_RECORD_PAGE_NOTES_TAB_UNIVERSAL_IDENTIFIER,
title: 'Notes',
position: 300,
icon: 'IconNotes',
layoutMode: PageLayoutTabLayoutMode.CANVAS,
widgets: [
{
universalIdentifier:
RESEND_EMAIL_RECORD_PAGE_NOTES_WIDGET_UNIVERSAL_IDENTIFIER,
title: 'Notes',
type: 'NOTES',
configuration: {
configurationType: 'NOTES',
},
},
],
},
{
universalIdentifier:
RESEND_EMAIL_RECORD_PAGE_FILES_TAB_UNIVERSAL_IDENTIFIER,
title: 'Files',
position: 400,
icon: 'IconPaperclip',
layoutMode: PageLayoutTabLayoutMode.CANVAS,
widgets: [
{
universalIdentifier:
RESEND_EMAIL_RECORD_PAGE_FILES_WIDGET_UNIVERSAL_IDENTIFIER,
title: 'Files',
type: 'FILES',
configuration: {
configurationType: 'FILES',
},
},
],
},
],
});

View file

@ -0,0 +1,142 @@
import {
RESEND_TEMPLATE_OBJECT_UNIVERSAL_IDENTIFIER,
RESEND_TEMPLATE_RECORD_PAGE_FILES_TAB_UNIVERSAL_IDENTIFIER,
RESEND_TEMPLATE_RECORD_PAGE_FILES_WIDGET_UNIVERSAL_IDENTIFIER,
RESEND_TEMPLATE_RECORD_PAGE_HOME_FIELDS_WIDGET_UNIVERSAL_IDENTIFIER,
RESEND_TEMPLATE_RECORD_PAGE_HOME_TAB_UNIVERSAL_IDENTIFIER,
RESEND_TEMPLATE_RECORD_PAGE_LAYOUT_UNIVERSAL_IDENTIFIER,
RESEND_TEMPLATE_RECORD_PAGE_NOTES_TAB_UNIVERSAL_IDENTIFIER,
RESEND_TEMPLATE_RECORD_PAGE_NOTES_WIDGET_UNIVERSAL_IDENTIFIER,
RESEND_TEMPLATE_RECORD_PAGE_PREVIEW_TAB_UNIVERSAL_IDENTIFIER,
RESEND_TEMPLATE_RECORD_PAGE_PREVIEW_WIDGET_UNIVERSAL_IDENTIFIER,
RESEND_TEMPLATE_RECORD_PAGE_TASKS_TAB_UNIVERSAL_IDENTIFIER,
RESEND_TEMPLATE_RECORD_PAGE_TASKS_WIDGET_UNIVERSAL_IDENTIFIER,
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';
import { definePageLayout, PageLayoutTabLayoutMode } from 'twenty-sdk';
export default definePageLayout({
universalIdentifier: RESEND_TEMPLATE_RECORD_PAGE_LAYOUT_UNIVERSAL_IDENTIFIER,
name: 'Resend Template Record Page',
type: 'RECORD_PAGE',
objectUniversalIdentifier: RESEND_TEMPLATE_OBJECT_UNIVERSAL_IDENTIFIER,
tabs: [
{
universalIdentifier: RESEND_TEMPLATE_RECORD_PAGE_HOME_TAB_UNIVERSAL_IDENTIFIER,
title: 'Home',
position: 50,
icon: 'IconHome',
layoutMode: PageLayoutTabLayoutMode.VERTICAL_LIST,
widgets: [
{
universalIdentifier:
RESEND_TEMPLATE_RECORD_PAGE_HOME_FIELDS_WIDGET_UNIVERSAL_IDENTIFIER,
title: 'Fields',
type: 'FIELDS',
configuration: {
configurationType: 'FIELDS',
},
},
],
},
{
universalIdentifier:
RESEND_TEMPLATE_RECORD_PAGE_PREVIEW_TAB_UNIVERSAL_IDENTIFIER,
title: 'Preview',
position: 75,
icon: 'IconEye',
layoutMode: PageLayoutTabLayoutMode.CANVAS,
widgets: [
{
universalIdentifier:
RESEND_TEMPLATE_RECORD_PAGE_PREVIEW_WIDGET_UNIVERSAL_IDENTIFIER,
title: 'Template Preview',
type: 'FRONT_COMPONENT',
configuration: {
configurationType: 'FRONT_COMPONENT',
frontComponentUniversalIdentifier:
TEMPLATE_HTML_VIEWER_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER,
},
},
],
},
{
universalIdentifier:
RESEND_TEMPLATE_RECORD_PAGE_TIMELINE_TAB_UNIVERSAL_IDENTIFIER,
title: 'Timeline',
position: 100,
icon: 'IconTimelineEvent',
layoutMode: PageLayoutTabLayoutMode.CANVAS,
widgets: [
{
universalIdentifier:
RESEND_TEMPLATE_RECORD_PAGE_TIMELINE_WIDGET_UNIVERSAL_IDENTIFIER,
title: 'Timeline',
type: 'TIMELINE',
configuration: {
configurationType: 'TIMELINE',
},
},
],
},
{
universalIdentifier:
RESEND_TEMPLATE_RECORD_PAGE_TASKS_TAB_UNIVERSAL_IDENTIFIER,
title: 'Tasks',
position: 200,
icon: 'IconCheckbox',
layoutMode: PageLayoutTabLayoutMode.CANVAS,
widgets: [
{
universalIdentifier:
RESEND_TEMPLATE_RECORD_PAGE_TASKS_WIDGET_UNIVERSAL_IDENTIFIER,
title: 'Tasks',
type: 'TASKS',
configuration: {
configurationType: 'TASKS',
},
},
],
},
{
universalIdentifier:
RESEND_TEMPLATE_RECORD_PAGE_NOTES_TAB_UNIVERSAL_IDENTIFIER,
title: 'Notes',
position: 300,
icon: 'IconNotes',
layoutMode: PageLayoutTabLayoutMode.CANVAS,
widgets: [
{
universalIdentifier:
RESEND_TEMPLATE_RECORD_PAGE_NOTES_WIDGET_UNIVERSAL_IDENTIFIER,
title: 'Notes',
type: 'NOTES',
configuration: {
configurationType: 'NOTES',
},
},
],
},
{
universalIdentifier:
RESEND_TEMPLATE_RECORD_PAGE_FILES_TAB_UNIVERSAL_IDENTIFIER,
title: 'Files',
position: 400,
icon: 'IconPaperclip',
layoutMode: PageLayoutTabLayoutMode.CANVAS,
widgets: [
{
universalIdentifier:
RESEND_TEMPLATE_RECORD_PAGE_FILES_WIDGET_UNIVERSAL_IDENTIFIER,
title: 'Files',
type: 'FILES',
configuration: {
configurationType: 'FILES',
},
},
],
},
],
});

View file

@ -0,0 +1,102 @@
import {
BROADCAST_CREATED_AT_FIELD_UNIVERSAL_IDENTIFIER,
BROADCAST_FROM_ADDRESS_FIELD_UNIVERSAL_IDENTIFIER,
BROADCAST_NAME_FIELD_UNIVERSAL_IDENTIFIER,
BROADCAST_SENT_AT_FIELD_UNIVERSAL_IDENTIFIER,
BROADCAST_STATUS_FIELD_UNIVERSAL_IDENTIFIER,
BROADCAST_SUBJECT_FIELD_UNIVERSAL_IDENTIFIER,
RESEND_BROADCAST_OBJECT_UNIVERSAL_IDENTIFIER,
RESEND_BROADCAST_VIEW_CREATED_AT_FIELD_UNIVERSAL_IDENTIFIER,
RESEND_BROADCAST_VIEW_EMAILS_FIELD_UNIVERSAL_IDENTIFIER,
RESEND_BROADCAST_VIEW_FROM_ADDRESS_FIELD_UNIVERSAL_IDENTIFIER,
RESEND_BROADCAST_VIEW_NAME_FIELD_UNIVERSAL_IDENTIFIER,
RESEND_BROADCAST_VIEW_SEGMENT_FIELD_UNIVERSAL_IDENTIFIER,
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_UNIVERSAL_IDENTIFIER,
RESEND_EMAILS_ON_BROADCAST_FIELD_UNIVERSAL_IDENTIFIER,
SEGMENT_ON_RESEND_BROADCAST_FIELD_UNIVERSAL_IDENTIFIER,
} from 'src/modules/resend/constants/universal-identifiers';
import { defineView } from 'twenty-sdk';
export default defineView({
universalIdentifier: RESEND_BROADCAST_VIEW_UNIVERSAL_IDENTIFIER,
name: 'Resend broadcasts',
objectUniversalIdentifier: RESEND_BROADCAST_OBJECT_UNIVERSAL_IDENTIFIER,
icon: 'IconSpeakerphone',
position: 0,
fields: [
{
universalIdentifier: RESEND_BROADCAST_VIEW_NAME_FIELD_UNIVERSAL_IDENTIFIER,
fieldMetadataUniversalIdentifier:
BROADCAST_NAME_FIELD_UNIVERSAL_IDENTIFIER,
isVisible: true,
size: 12,
position: 0,
},
{
universalIdentifier:
RESEND_BROADCAST_VIEW_SUBJECT_FIELD_UNIVERSAL_IDENTIFIER,
fieldMetadataUniversalIdentifier:
BROADCAST_SUBJECT_FIELD_UNIVERSAL_IDENTIFIER,
isVisible: true,
size: 12,
position: 1,
},
{
universalIdentifier:
RESEND_BROADCAST_VIEW_FROM_ADDRESS_FIELD_UNIVERSAL_IDENTIFIER,
fieldMetadataUniversalIdentifier:
BROADCAST_FROM_ADDRESS_FIELD_UNIVERSAL_IDENTIFIER,
isVisible: true,
size: 12,
position: 2,
},
{
universalIdentifier:
RESEND_BROADCAST_VIEW_STATUS_FIELD_UNIVERSAL_IDENTIFIER,
fieldMetadataUniversalIdentifier:
BROADCAST_STATUS_FIELD_UNIVERSAL_IDENTIFIER,
isVisible: true,
size: 12,
position: 3,
},
{
universalIdentifier:
RESEND_BROADCAST_VIEW_SENT_AT_FIELD_UNIVERSAL_IDENTIFIER,
fieldMetadataUniversalIdentifier:
BROADCAST_SENT_AT_FIELD_UNIVERSAL_IDENTIFIER,
isVisible: true,
size: 12,
position: 4,
},
{
universalIdentifier:
RESEND_BROADCAST_VIEW_CREATED_AT_FIELD_UNIVERSAL_IDENTIFIER,
fieldMetadataUniversalIdentifier:
BROADCAST_CREATED_AT_FIELD_UNIVERSAL_IDENTIFIER,
isVisible: true,
size: 12,
position: 5,
},
{
universalIdentifier:
RESEND_BROADCAST_VIEW_SEGMENT_FIELD_UNIVERSAL_IDENTIFIER,
fieldMetadataUniversalIdentifier:
SEGMENT_ON_RESEND_BROADCAST_FIELD_UNIVERSAL_IDENTIFIER,
isVisible: true,
size: 12,
position: 6,
},
{
universalIdentifier:
RESEND_BROADCAST_VIEW_EMAILS_FIELD_UNIVERSAL_IDENTIFIER,
fieldMetadataUniversalIdentifier:
RESEND_EMAILS_ON_BROADCAST_FIELD_UNIVERSAL_IDENTIFIER,
isVisible: true,
size: 12,
position: 7,
},
],
});

View file

@ -0,0 +1,89 @@
import {
CONTACT_CREATED_AT_FIELD_UNIVERSAL_IDENTIFIER,
CONTACT_EMAIL_FIELD_UNIVERSAL_IDENTIFIER,
NAME_FIELD_UNIVERSAL_IDENTIFIER,
PERSON_ON_RESEND_CONTACT_FIELD_UNIVERSAL_IDENTIFIER,
RESEND_CONTACT_OBJECT_UNIVERSAL_IDENTIFIER,
RESEND_CONTACT_VIEW_CREATED_AT_FIELD_UNIVERSAL_IDENTIFIER,
RESEND_CONTACT_VIEW_EMAILS_FIELD_UNIVERSAL_IDENTIFIER,
RESEND_CONTACT_VIEW_EMAIL_FIELD_UNIVERSAL_IDENTIFIER,
RESEND_CONTACT_VIEW_NAME_FIELD_UNIVERSAL_IDENTIFIER,
RESEND_CONTACT_VIEW_PERSON_FIELD_UNIVERSAL_IDENTIFIER,
RESEND_CONTACT_VIEW_SEGMENT_FIELD_UNIVERSAL_IDENTIFIER,
RESEND_CONTACT_VIEW_UNIVERSAL_IDENTIFIER,
RESEND_CONTACT_VIEW_UNSUBSCRIBED_FIELD_UNIVERSAL_IDENTIFIER,
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';
import { defineView } from 'twenty-sdk';
export default defineView({
universalIdentifier: RESEND_CONTACT_VIEW_UNIVERSAL_IDENTIFIER,
name: 'Resend contacts',
objectUniversalIdentifier: RESEND_CONTACT_OBJECT_UNIVERSAL_IDENTIFIER,
icon: 'IconAddressBook',
position: 0,
fields: [
{
universalIdentifier: RESEND_CONTACT_VIEW_NAME_FIELD_UNIVERSAL_IDENTIFIER,
fieldMetadataUniversalIdentifier: NAME_FIELD_UNIVERSAL_IDENTIFIER,
isVisible: true,
size: 12,
position: 0,
},
{
universalIdentifier: RESEND_CONTACT_VIEW_EMAIL_FIELD_UNIVERSAL_IDENTIFIER,
fieldMetadataUniversalIdentifier:
CONTACT_EMAIL_FIELD_UNIVERSAL_IDENTIFIER,
isVisible: true,
size: 12,
position: 1,
},
{
universalIdentifier:
RESEND_CONTACT_VIEW_UNSUBSCRIBED_FIELD_UNIVERSAL_IDENTIFIER,
fieldMetadataUniversalIdentifier:
UNSUBSCRIBED_FIELD_UNIVERSAL_IDENTIFIER,
isVisible: true,
size: 12,
position: 2,
},
{
universalIdentifier:
RESEND_CONTACT_VIEW_CREATED_AT_FIELD_UNIVERSAL_IDENTIFIER,
fieldMetadataUniversalIdentifier:
CONTACT_CREATED_AT_FIELD_UNIVERSAL_IDENTIFIER,
isVisible: true,
size: 12,
position: 3,
},
{
universalIdentifier:
RESEND_CONTACT_VIEW_PERSON_FIELD_UNIVERSAL_IDENTIFIER,
fieldMetadataUniversalIdentifier:
PERSON_ON_RESEND_CONTACT_FIELD_UNIVERSAL_IDENTIFIER,
isVisible: true,
size: 12,
position: 4,
},
{
universalIdentifier:
RESEND_CONTACT_VIEW_SEGMENT_FIELD_UNIVERSAL_IDENTIFIER,
fieldMetadataUniversalIdentifier:
SEGMENT_ON_RESEND_CONTACT_FIELD_UNIVERSAL_IDENTIFIER,
isVisible: true,
size: 12,
position: 5,
},
{
universalIdentifier:
RESEND_CONTACT_VIEW_EMAILS_FIELD_UNIVERSAL_IDENTIFIER,
fieldMetadataUniversalIdentifier:
RESEND_EMAILS_ON_CONTACT_FIELD_UNIVERSAL_IDENTIFIER,
isVisible: true,
size: 12,
position: 6,
},
],
});

View file

@ -0,0 +1,91 @@
import {
BROADCAST_ON_RESEND_EMAIL_FIELD_UNIVERSAL_IDENTIFIER,
CONTACT_ON_RESEND_EMAIL_FIELD_UNIVERSAL_IDENTIFIER,
EMAIL_CREATED_AT_FIELD_UNIVERSAL_IDENTIFIER,
FROM_ADDRESS_FIELD_UNIVERSAL_IDENTIFIER,
LAST_EVENT_FIELD_UNIVERSAL_IDENTIFIER,
PERSON_ON_RESEND_EMAIL_FIELD_UNIVERSAL_IDENTIFIER,
RESEND_EMAIL_OBJECT_UNIVERSAL_IDENTIFIER,
RESEND_EMAIL_VIEW_BROADCAST_FIELD_UNIVERSAL_IDENTIFIER,
RESEND_EMAIL_VIEW_CONTACT_FIELD_UNIVERSAL_IDENTIFIER,
RESEND_EMAIL_VIEW_CREATED_AT_FIELD_UNIVERSAL_IDENTIFIER,
RESEND_EMAIL_VIEW_FROM_ADDRESS_FIELD_UNIVERSAL_IDENTIFIER,
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_UNIVERSAL_IDENTIFIER,
SUBJECT_FIELD_UNIVERSAL_IDENTIFIER,
} from 'src/modules/resend/constants/universal-identifiers';
import { defineView } from 'twenty-sdk';
export default defineView({
universalIdentifier: RESEND_EMAIL_VIEW_UNIVERSAL_IDENTIFIER,
name: 'Resend emails',
objectUniversalIdentifier: RESEND_EMAIL_OBJECT_UNIVERSAL_IDENTIFIER,
icon: 'IconMail',
position: 0,
fields: [
{
universalIdentifier:
RESEND_EMAIL_VIEW_SUBJECT_FIELD_UNIVERSAL_IDENTIFIER,
fieldMetadataUniversalIdentifier: SUBJECT_FIELD_UNIVERSAL_IDENTIFIER,
isVisible: true,
size: 12,
position: 0,
},
{
universalIdentifier:
RESEND_EMAIL_VIEW_FROM_ADDRESS_FIELD_UNIVERSAL_IDENTIFIER,
fieldMetadataUniversalIdentifier:
FROM_ADDRESS_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,
fieldMetadataUniversalIdentifier:
PERSON_ON_RESEND_EMAIL_FIELD_UNIVERSAL_IDENTIFIER,
isVisible: true,
size: 12,
position: 4,
},
{
universalIdentifier:
RESEND_EMAIL_VIEW_CONTACT_FIELD_UNIVERSAL_IDENTIFIER,
fieldMetadataUniversalIdentifier:
CONTACT_ON_RESEND_EMAIL_FIELD_UNIVERSAL_IDENTIFIER,
isVisible: true,
size: 12,
position: 5,
},
{
universalIdentifier:
RESEND_EMAIL_VIEW_BROADCAST_FIELD_UNIVERSAL_IDENTIFIER,
fieldMetadataUniversalIdentifier:
BROADCAST_ON_RESEND_EMAIL_FIELD_UNIVERSAL_IDENTIFIER,
isVisible: true,
size: 12,
position: 6,
},
],
});

View file

@ -0,0 +1,58 @@
import {
RESEND_BROADCASTS_ON_SEGMENT_FIELD_UNIVERSAL_IDENTIFIER,
RESEND_CONTACTS_ON_SEGMENT_FIELD_UNIVERSAL_IDENTIFIER,
RESEND_SEGMENT_OBJECT_UNIVERSAL_IDENTIFIER,
RESEND_SEGMENT_VIEW_BROADCASTS_FIELD_UNIVERSAL_IDENTIFIER,
RESEND_SEGMENT_VIEW_CONTACTS_FIELD_UNIVERSAL_IDENTIFIER,
RESEND_SEGMENT_VIEW_CREATED_AT_FIELD_UNIVERSAL_IDENTIFIER,
RESEND_SEGMENT_VIEW_NAME_FIELD_UNIVERSAL_IDENTIFIER,
RESEND_SEGMENT_VIEW_UNIVERSAL_IDENTIFIER,
SEGMENT_CREATED_AT_FIELD_UNIVERSAL_IDENTIFIER,
SEGMENT_NAME_FIELD_UNIVERSAL_IDENTIFIER,
} from 'src/modules/resend/constants/universal-identifiers';
import { defineView } from 'twenty-sdk';
export default defineView({
universalIdentifier: RESEND_SEGMENT_VIEW_UNIVERSAL_IDENTIFIER,
name: 'Resend segments',
objectUniversalIdentifier: RESEND_SEGMENT_OBJECT_UNIVERSAL_IDENTIFIER,
icon: 'IconUsersGroup',
position: 0,
fields: [
{
universalIdentifier: RESEND_SEGMENT_VIEW_NAME_FIELD_UNIVERSAL_IDENTIFIER,
fieldMetadataUniversalIdentifier:
SEGMENT_NAME_FIELD_UNIVERSAL_IDENTIFIER,
isVisible: true,
size: 12,
position: 0,
},
{
universalIdentifier:
RESEND_SEGMENT_VIEW_CREATED_AT_FIELD_UNIVERSAL_IDENTIFIER,
fieldMetadataUniversalIdentifier:
SEGMENT_CREATED_AT_FIELD_UNIVERSAL_IDENTIFIER,
isVisible: true,
size: 12,
position: 1,
},
{
universalIdentifier:
RESEND_SEGMENT_VIEW_CONTACTS_FIELD_UNIVERSAL_IDENTIFIER,
fieldMetadataUniversalIdentifier:
RESEND_CONTACTS_ON_SEGMENT_FIELD_UNIVERSAL_IDENTIFIER,
isVisible: true,
size: 12,
position: 2,
},
{
universalIdentifier:
RESEND_SEGMENT_VIEW_BROADCASTS_FIELD_UNIVERSAL_IDENTIFIER,
fieldMetadataUniversalIdentifier:
RESEND_BROADCASTS_ON_SEGMENT_FIELD_UNIVERSAL_IDENTIFIER,
isVisible: true,
size: 12,
position: 3,
},
],
});

View file

@ -0,0 +1,80 @@
import {
RESEND_TEMPLATE_OBJECT_UNIVERSAL_IDENTIFIER,
RESEND_TEMPLATE_VIEW_CREATED_AT_FIELD_UNIVERSAL_IDENTIFIER,
RESEND_TEMPLATE_VIEW_FROM_ADDRESS_FIELD_UNIVERSAL_IDENTIFIER,
RESEND_TEMPLATE_VIEW_NAME_FIELD_UNIVERSAL_IDENTIFIER,
RESEND_TEMPLATE_VIEW_STATUS_FIELD_UNIVERSAL_IDENTIFIER,
RESEND_TEMPLATE_VIEW_SUBJECT_FIELD_UNIVERSAL_IDENTIFIER,
RESEND_TEMPLATE_VIEW_UNIVERSAL_IDENTIFIER,
RESEND_TEMPLATE_VIEW_UPDATED_AT_FIELD_UNIVERSAL_IDENTIFIER,
TEMPLATE_CREATED_AT_FIELD_UNIVERSAL_IDENTIFIER,
TEMPLATE_FROM_ADDRESS_FIELD_UNIVERSAL_IDENTIFIER,
TEMPLATE_NAME_FIELD_UNIVERSAL_IDENTIFIER,
TEMPLATE_STATUS_FIELD_UNIVERSAL_IDENTIFIER,
TEMPLATE_SUBJECT_FIELD_UNIVERSAL_IDENTIFIER,
TEMPLATE_UPDATED_AT_FIELD_UNIVERSAL_IDENTIFIER,
} from 'src/modules/resend/constants/universal-identifiers';
import { defineView } from 'twenty-sdk';
export default defineView({
universalIdentifier: RESEND_TEMPLATE_VIEW_UNIVERSAL_IDENTIFIER,
name: 'Resend templates',
objectUniversalIdentifier: RESEND_TEMPLATE_OBJECT_UNIVERSAL_IDENTIFIER,
icon: 'IconTemplate',
position: 0,
fields: [
{
universalIdentifier: RESEND_TEMPLATE_VIEW_NAME_FIELD_UNIVERSAL_IDENTIFIER,
fieldMetadataUniversalIdentifier:
TEMPLATE_NAME_FIELD_UNIVERSAL_IDENTIFIER,
isVisible: true,
size: 12,
position: 0,
},
{
universalIdentifier:
RESEND_TEMPLATE_VIEW_SUBJECT_FIELD_UNIVERSAL_IDENTIFIER,
fieldMetadataUniversalIdentifier:
TEMPLATE_SUBJECT_FIELD_UNIVERSAL_IDENTIFIER,
isVisible: true,
size: 12,
position: 1,
},
{
universalIdentifier:
RESEND_TEMPLATE_VIEW_FROM_ADDRESS_FIELD_UNIVERSAL_IDENTIFIER,
fieldMetadataUniversalIdentifier:
TEMPLATE_FROM_ADDRESS_FIELD_UNIVERSAL_IDENTIFIER,
isVisible: true,
size: 12,
position: 2,
},
{
universalIdentifier:
RESEND_TEMPLATE_VIEW_STATUS_FIELD_UNIVERSAL_IDENTIFIER,
fieldMetadataUniversalIdentifier:
TEMPLATE_STATUS_FIELD_UNIVERSAL_IDENTIFIER,
isVisible: true,
size: 12,
position: 3,
},
{
universalIdentifier:
RESEND_TEMPLATE_VIEW_UPDATED_AT_FIELD_UNIVERSAL_IDENTIFIER,
fieldMetadataUniversalIdentifier:
TEMPLATE_UPDATED_AT_FIELD_UNIVERSAL_IDENTIFIER,
isVisible: true,
size: 12,
position: 4,
},
{
universalIdentifier:
RESEND_TEMPLATE_VIEW_CREATED_AT_FIELD_UNIVERSAL_IDENTIFIER,
fieldMetadataUniversalIdentifier:
TEMPLATE_CREATED_AT_FIELD_UNIVERSAL_IDENTIFIER,
isVisible: true,
size: 12,
position: 5,
},
],
});

View file

@ -0,0 +1,4 @@
export type EmailsField = {
primaryEmail: string;
additionalEmails: string[] | null;
};

View file

@ -0,0 +1,10 @@
import type { EmailsField, FullNameField } from 'twenty-sdk';
export type ResendContactRecord = {
id: string;
resendId?: string;
email?: EmailsField;
name?: FullNameField;
unsubscribed?: boolean;
lastSyncedFromResend?: string;
};

View file

@ -0,0 +1,5 @@
export type ResendSegmentRecord = {
id: string;
resendId?: string;
name?: string;
};

View file

@ -0,0 +1,2 @@
export const capitalize = (str: string): string =>
str.charAt(0).toUpperCase() + str.slice(1);

View file

@ -0,0 +1,116 @@
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/,
);
});
});

View file

@ -0,0 +1,53 @@
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;
};

View file

@ -0,0 +1,62 @@
import { isNonEmptyString } from '@sniptt/guards';
import { CoreApiClient } from 'twenty-client-sdk/core';
import { isDefined } from 'twenty-shared/utils';
type PersonName = {
firstName?: string;
lastName?: string;
};
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 { people } = await client.query({
people: {
edges: { node: { id: true } },
__args: {
filter: {
emails: {
primaryEmail: { eq: email },
},
},
first: 1,
},
},
});
const existingPersonId = (people as PeopleConnection | undefined)?.edges[0]
?.node?.id;
if (isDefined(existingPersonId)) {
return existingPersonId;
}
const { createPerson } = await client.mutation({
createPerson: {
__args: {
data: {
name: {
firstName: name?.firstName ?? '',
lastName: name?.lastName ?? '',
},
emails: {
primaryEmail: email,
},
},
},
id: true,
},
});
return (createPerson as { id: string } | undefined)?.id;
};

View file

@ -0,0 +1,34 @@
import { CoreApiClient } from 'twenty-client-sdk/core';
type ConnectionResult = {
edges: Array<{ node: { id: string; resendId: string } }>;
};
export const findRecordByResendId = async (
client: CoreApiClient,
objectNamePlural: string,
resendId: string,
): Promise<string | undefined> => {
const result = await client.query({
[objectNamePlural]: {
__args: {
filter: {
resendId: { eq: resendId },
},
first: 1,
},
edges: {
node: {
id: true,
resendId: true,
},
},
},
});
const connection = (result as Record<string, unknown>)[objectNamePlural] as
| ConnectionResult
| undefined;
return connection?.edges[0]?.node.id;
};

View file

@ -0,0 +1,2 @@
export const getErrorMessage = (error: unknown): string =>
error instanceof Error ? error.message : String(error);

View file

@ -0,0 +1,12 @@
import { Resend } from 'resend';
import { isDefined } from 'twenty-shared/utils';
export const getResendClient = (): Resend => {
const apiKey = process.env.RESEND_API_KEY;
if (!isDefined(apiKey)) {
throw new Error('RESEND_API_KEY environment variable is not set');
}
return new Resend(apiKey);
};

View file

@ -0,0 +1,45 @@
import { isDefined } from 'twenty-shared/utils';
export type LastEvent =
| 'SENT'
| 'DELIVERED'
| 'DELIVERY_DELAYED'
| 'COMPLAINED'
| 'BOUNCED'
| 'OPENED'
| 'CLICKED'
| 'SCHEDULED'
| 'QUEUED'
| 'FAILED'
| 'CANCELED'
| 'RECEIVED'
| 'SUPPRESSED';
const EVENT_TO_LAST_EVENT: Record<string, LastEvent> = {
sent: 'SENT',
delivered: 'DELIVERED',
delivery_delayed: 'DELIVERY_DELAYED',
complained: 'COMPLAINED',
bounced: 'BOUNCED',
opened: 'OPENED',
clicked: 'CLICKED',
scheduled: 'SCHEDULED',
queued: 'QUEUED',
failed: 'FAILED',
canceled: 'CANCELED',
received: 'RECEIVED',
suppressed: 'SUPPRESSED',
};
export const mapLastEvent = (lastEvent: string): LastEvent | null => {
const key = lastEvent.replace(/^email\./, '').toLowerCase();
const mapped = EVENT_TO_LAST_EVENT[key];
if (!isDefined(mapped)) {
console.warn(`[resend] Unknown email event: ${lastEvent}`);
return null;
}
return mapped;
};

View file

@ -0,0 +1,14 @@
import type { EmailsField } from 'src/modules/resend/shared/types/emails-field';
export const toEmailsField = (
value: string | string[] | undefined | null,
): EmailsField => {
if (Array.isArray(value)) {
return {
primaryEmail: value[0] ?? '',
additionalEmails: value.length > 1 ? value.slice(1) : null,
};
}
return { primaryEmail: value ?? '', additionalEmails: null };
};

View file

@ -0,0 +1,8 @@
import { isDefined } from 'twenty-shared/utils';
export const toIsoString = (date: string): string =>
new Date(date).toISOString();
export const toIsoStringOrNull = (
date: string | null | undefined,
): string | null => (isDefined(date) ? toIsoString(date) : null);

View file

@ -0,0 +1,51 @@
const sleep = (ms: number) =>
new Promise<void>((resolve) => setTimeout(resolve, ms));
const isRateLimitError = (error: unknown): boolean => {
const text =
error instanceof Error
? error.message
: typeof error === 'object' && error !== null
? JSON.stringify(error)
: '';
const lower = text.toLowerCase();
return (
lower.includes('rate limit') ||
lower.includes('rate_limit') ||
lower.includes('too many requests')
);
};
const MAX_RETRIES = 5;
const BASE_DELAY_MS = 1000;
const MIN_INTERVAL_MS = 220;
let lastCallTimestamp = 0;
export const withRateLimitRetry = async <T>(
fn: () => Promise<T>,
): Promise<T> => {
for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
const elapsed = Date.now() - lastCallTimestamp;
if (elapsed < MIN_INTERVAL_MS) await sleep(MIN_INTERVAL_MS - elapsed);
lastCallTimestamp = Date.now();
try {
return await fn();
} catch (error) {
if (!isRateLimitError(error) || attempt === MAX_RETRIES) throw error;
const delayMs = BASE_DELAY_MS * 2 ** attempt;
console.warn(
`[resend] Rate limited, retrying in ${delayMs}ms (attempt ${attempt + 1}/${MAX_RETRIES})`,
);
await sleep(delayMs);
}
}
throw new Error('Unreachable');
};

View file

@ -0,0 +1,83 @@
import { isNonEmptyString } from '@sniptt/guards';
import { CoreApiClient } from 'twenty-client-sdk/core';
import {
defineLogicFunction,
type DatabaseEventPayload,
type ObjectRecordCreateEvent,
} from 'twenty-sdk';
import { isDefined } from 'twenty-shared/utils';
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';
type ContactCreateEvent = DatabaseEventPayload<
ObjectRecordCreateEvent<ResendContactRecord>
>;
const handler = async (
event: ContactCreateEvent,
): Promise<object | undefined> => {
const { after } = event.properties;
if (isNonEmptyString(after.resendId)) {
return { skipped: true, reason: 'record already has resendId (inbound sync)' };
}
const email = after.email?.primaryEmail;
if (!isNonEmptyString(email)) {
return { skipped: true, reason: 'no email on record' };
}
const resend = getResendClient();
const { data, error } = await resend.contacts.create({
email,
firstName: after.name?.firstName ?? undefined,
lastName: after.name?.lastName ?? undefined,
unsubscribed: after.unsubscribed ?? false,
});
if (isDefined(error) || !isDefined(data)) {
throw new Error(
`Failed to create Resend contact: ${JSON.stringify(error)}`,
);
}
const client = new CoreApiClient();
const personId = await findOrCreatePerson(client, email, {
firstName: after.name?.firstName ?? undefined,
lastName: after.name?.lastName ?? undefined,
});
await client.mutation({
updateResendContact: {
__args: {
id: event.recordId,
data: {
resendId: data.id,
lastSyncedFromResend: new Date().toISOString(),
...(isDefined(personId) && { personId }),
},
},
id: true,
},
});
return { synced: true, resendId: data.id, twentyId: event.recordId, personId };
};
export default defineLogicFunction({
universalIdentifier: ON_RESEND_CONTACT_CREATED_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER,
name: 'on-resend-contact-created',
description:
'Creates a contact in Resend when a new resendContact record is created in Twenty',
timeoutSeconds: 30,
handler,
databaseEventTriggerSettings: {
eventName: 'resendContact.created',
},
});

View file

@ -0,0 +1,55 @@
import { isNonEmptyString } from '@sniptt/guards';
import {
defineLogicFunction,
type DatabaseEventPayload,
type ObjectRecordDeleteEvent,
} from 'twenty-sdk';
import { isDefined } from 'twenty-shared/utils';
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';
type ContactDeleteEvent = DatabaseEventPayload<
ObjectRecordDeleteEvent<ResendContactRecord>
>;
const handler = async (
event: ContactDeleteEvent,
): Promise<object | undefined> => {
const resendId = event.properties.before?.resendId;
if (!isNonEmptyString(resendId)) {
return { skipped: true, reason: 'no resendId on record' };
}
const resend = getResendClient();
const { error } = await resend.contacts.remove({ id: resendId });
if (isDefined(error)) {
const errorString = JSON.stringify(error);
if (errorString.includes('not_found')) {
return { skipped: true, reason: 'contact already deleted on Resend' };
}
throw new Error(
`Failed to delete Resend contact ${resendId}: ${errorString}`,
);
}
return { synced: true, resendId, action: 'deleted' };
};
export default defineLogicFunction({
universalIdentifier: ON_RESEND_CONTACT_DELETED_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER,
name: 'on-resend-contact-deleted',
description:
'Removes a contact from Resend when a resendContact record is deleted in Twenty',
timeoutSeconds: 30,
handler,
databaseEventTriggerSettings: {
eventName: 'resendContact.deleted',
},
});

View file

@ -0,0 +1,104 @@
import { isNonEmptyString } from '@sniptt/guards';
import { CoreApiClient } from 'twenty-client-sdk/core';
import {
defineLogicFunction,
type DatabaseEventPayload,
type ObjectRecordUpdateEvent,
} from 'twenty-sdk';
import { isDefined } from 'twenty-shared/utils';
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';
type ContactUpdateEvent = DatabaseEventPayload<
ObjectRecordUpdateEvent<ResendContactRecord>
>;
const handler = async (
event: ContactUpdateEvent,
): Promise<object | undefined> => {
if (event.properties.updatedFields?.includes('lastSyncedFromResend')) {
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 updatePayload: Record<string, unknown> = { id: resendId };
if (event.properties.updatedFields?.includes('unsubscribed')) {
updatePayload.unsubscribed = after.unsubscribed;
}
if (event.properties.updatedFields?.includes('name')) {
updatePayload.firstName = after.name?.firstName ?? null;
updatePayload.lastName = after.name?.lastName ?? null;
}
if (event.properties.updatedFields?.includes('email')) {
updatePayload.email = after.email?.primaryEmail;
}
if (Object.keys(updatePayload).length <= 1) {
return { skipped: true, reason: 'no relevant fields changed' };
}
const { error } = await resend.contacts.update(
updatePayload as Parameters<typeof resend.contacts.update>[0],
);
if (isDefined(error)) {
throw new Error(
`Failed to update Resend contact ${resendId}: ${JSON.stringify(error)}`,
);
}
let personId: string | undefined;
if (event.properties.updatedFields?.includes('email')) {
const email = after.email?.primaryEmail;
const client = new CoreApiClient();
personId = await findOrCreatePerson(client, email, {
firstName: after.name?.firstName ?? undefined,
lastName: after.name?.lastName ?? undefined,
});
if (isDefined(personId)) {
await client.mutation({
updateResendContact: {
__args: { id: event.recordId, data: { personId } },
id: true,
},
});
}
}
return {
synced: true,
resendId,
updatedFields: Object.keys(updatePayload).filter((k) => k !== 'id'),
personId,
};
};
export default defineLogicFunction({
universalIdentifier: ON_RESEND_CONTACT_UPDATED_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER,
name: 'on-resend-contact-updated',
description:
'Pushes contact field changes to Resend when a resendContact record is updated in Twenty',
timeoutSeconds: 30,
handler,
databaseEventTriggerSettings: {
eventName: 'resendContact.updated',
updatedFields: ['unsubscribed', 'name', 'email'],
},
});

View file

@ -0,0 +1,64 @@
import { isNonEmptyString } from '@sniptt/guards';
import { CoreApiClient } from 'twenty-client-sdk/core';
import {
defineLogicFunction,
type DatabaseEventPayload,
type ObjectRecordCreateEvent,
} from 'twenty-sdk';
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';
type SegmentCreateEvent = DatabaseEventPayload<
ObjectRecordCreateEvent<ResendSegmentRecord>
>;
const handler = async (
event: SegmentCreateEvent,
): Promise<object | undefined> => {
const { after } = event.properties;
if (isNonEmptyString(after.resendId)) {
return { skipped: true, reason: 'record already has resendId (inbound sync)' };
}
const name = after.name;
if (!isNonEmptyString(name)) {
return { skipped: true, reason: 'no name on record' };
}
const resend = getResendClient();
const client = new CoreApiClient();
const resendId = await findOrCreateResendSegment(resend, client, name);
await client.mutation({
updateResendSegment: {
__args: {
id: event.recordId,
data: {
resendId,
lastSyncedFromResend: new Date().toISOString(),
},
},
id: true,
},
});
return { synced: true, resendId, twentyId: event.recordId };
};
export default defineLogicFunction({
universalIdentifier: ON_RESEND_SEGMENT_CREATED_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER,
name: 'on-resend-segment-created',
description:
'Creates a segment in Resend when a new resendSegment record is created in Twenty',
timeoutSeconds: 30,
handler,
databaseEventTriggerSettings: {
eventName: 'resendSegment.created',
},
});

View file

@ -0,0 +1,55 @@
import { isNonEmptyString } from '@sniptt/guards';
import {
defineLogicFunction,
type DatabaseEventPayload,
type ObjectRecordDeleteEvent,
} from 'twenty-sdk';
import { isDefined } from 'twenty-shared/utils';
import { ON_RESEND_SEGMENT_DELETED_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER } from 'src/modules/resend/constants/universal-identifiers';
import type { ResendSegmentRecord } from 'src/modules/resend/shared/types/resend-segment-record';
import { getResendClient } from 'src/modules/resend/shared/utils/get-resend-client';
type SegmentDeleteEvent = DatabaseEventPayload<
ObjectRecordDeleteEvent<ResendSegmentRecord>
>;
const handler = async (
event: SegmentDeleteEvent,
): Promise<object | undefined> => {
const resendId = event.properties.before?.resendId;
if (!isNonEmptyString(resendId)) {
return { skipped: true, reason: 'no resendId on record' };
}
const resend = getResendClient();
const { error } = await resend.segments.remove(resendId);
if (isDefined(error)) {
const errorString = JSON.stringify(error);
if (errorString.includes('not_found')) {
return { skipped: true, reason: 'segment already deleted on Resend' };
}
throw new Error(
`Failed to delete Resend segment ${resendId}: ${errorString}`,
);
}
return { synced: true, resendId, action: 'deleted' };
};
export default defineLogicFunction({
universalIdentifier: ON_RESEND_SEGMENT_DELETED_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER,
name: 'on-resend-segment-deleted',
description:
'Removes a segment from Resend when a resendSegment record is deleted in Twenty',
timeoutSeconds: 30,
handler,
databaseEventTriggerSettings: {
eventName: 'resendSegment.deleted',
},
});

View file

@ -0,0 +1,224 @@
import { describe, expect, it, vi } from 'vitest';
import type { StepOutcome } from 'src/modules/resend/sync/types/step-outcome';
import type { SyncResult } from 'src/modules/resend/sync/types/sync-result';
import type { SyncStepResult } from 'src/modules/resend/sync/types/sync-step-result';
import { orchestrateSyncResend } from 'src/modules/resend/sync/utils/orchestrate-sync-resend';
import {
MAX_ERRORS_IN_THROWN_MESSAGE,
reportAndThrowIfErrors,
} from 'src/modules/resend/sync/utils/report-and-throw-if-errors';
import type { SegmentIdMap } from 'src/modules/resend/sync/utils/sync-segments';
const emptyResult = (): SyncResult => ({
fetched: 0,
created: 0,
updated: 0,
errors: [],
});
const emptySegmentMap: SegmentIdMap = new Map();
const okSegments = (): Promise<SyncStepResult<SegmentIdMap>> =>
Promise.resolve({ result: emptyResult(), value: emptySegmentMap });
const okTemplates = (): Promise<SyncStepResult> =>
Promise.resolve({ result: emptyResult(), value: undefined });
const okStep = (): Promise<SyncStepResult> =>
Promise.resolve({ result: emptyResult(), value: undefined });
describe('orchestrateSyncResend', () => {
it('runs segments, templates, contacts and emails concurrently', async () => {
const order: string[] = [];
const trackStart =
<T,>(name: string, value: T) =>
(): Promise<SyncStepResult<T>> => {
order.push(`${name}:start`);
return new Promise((resolve) => {
setImmediate(() => {
order.push(`${name}:end`);
resolve({ result: emptyResult(), value });
});
});
};
await orchestrateSyncResend({
syncSegments: trackStart('segments', emptySegmentMap),
syncTemplates: trackStart('templates', undefined),
syncContacts: trackStart('contacts', undefined),
syncEmails: trackStart('emails', undefined),
syncBroadcasts: () => okStep(),
});
expect(order.slice(0, 4)).toEqual([
'segments:start',
'templates:start',
'contacts:start',
'emails:start',
]);
});
it('runs broadcasts after segments resolved', async () => {
const broadcastsArgs: SegmentIdMap[] = [];
const segmentMap: SegmentIdMap = new Map([['seg-1', 'twenty-seg-1']]);
const outcomes = await orchestrateSyncResend({
syncSegments: () =>
Promise.resolve({ result: emptyResult(), value: segmentMap }),
syncTemplates: okTemplates,
syncContacts: () => okStep(),
syncEmails: () => okStep(),
syncBroadcasts: (s) => {
broadcastsArgs.push(s);
return okStep();
},
});
expect(broadcastsArgs).toHaveLength(1);
expect(broadcastsArgs[0]).toBe(segmentMap);
const broadcasts = outcomes.find(
(outcome) => outcome.name === 'broadcasts',
);
expect(broadcasts?.status).toBe('ok');
});
it('runs broadcasts when templates fails but segments succeeds', async () => {
const syncBroadcasts = vi.fn(() => okStep());
const outcomes = await orchestrateSyncResend({
syncSegments: okSegments,
syncTemplates: () => Promise.reject(new Error('templates boom')),
syncContacts: okStep,
syncEmails: okStep,
syncBroadcasts,
});
expect(syncBroadcasts).toHaveBeenCalledTimes(1);
const broadcasts = outcomes.find(
(outcome) => outcome.name === 'broadcasts',
);
expect(broadcasts?.status).toBe('ok');
});
it('skips broadcasts with structured reason when segments fails', async () => {
const syncBroadcasts = vi.fn(() => okStep());
const outcomes = await orchestrateSyncResend({
syncSegments: () => Promise.reject(new Error('segments boom')),
syncTemplates: okTemplates,
syncContacts: okStep,
syncEmails: okStep,
syncBroadcasts,
});
expect(syncBroadcasts).not.toHaveBeenCalled();
const broadcasts = outcomes.find(
(outcome) => outcome.name === 'broadcasts',
);
expect(broadcasts?.status).toBe('skipped');
if (broadcasts?.status !== 'skipped') {
throw new Error('expected skipped outcome');
}
expect(broadcasts.reason).toContain('segments');
});
});
describe('reportAndThrowIfErrors', () => {
it('does nothing when no step has errors', () => {
const outcomes: ReadonlyArray<StepOutcome<unknown>> = [
{
name: 'segments',
status: 'ok',
durationMs: 1,
result: emptyResult(),
value: undefined,
},
];
expect(() => reportAndThrowIfErrors(outcomes)).not.toThrow();
});
it('surfaces non-throwing per-item errors collected in result.errors', () => {
const outcomes: ReadonlyArray<StepOutcome<unknown>> = [
{
name: 'contacts',
status: 'ok',
durationMs: 1,
result: {
fetched: 1,
created: 0,
updated: 0,
errors: ['contact 123: boom'],
},
value: undefined,
},
];
expect(() => reportAndThrowIfErrors(outcomes)).toThrowError(
/Sync completed with 1 error/,
);
expect(() => reportAndThrowIfErrors(outcomes)).toThrowError(
/\[contacts\] contact 123: boom/,
);
});
it('surfaces step-level failures', () => {
const outcomes: ReadonlyArray<StepOutcome<unknown>> = [
{
name: 'segments',
status: 'failed',
durationMs: 5,
error: 'top level boom',
},
];
expect(() => reportAndThrowIfErrors(outcomes)).toThrowError(
/\[segments\] top level boom/,
);
});
it('truncates the thrown message after MAX_ERRORS_IN_THROWN_MESSAGE entries', () => {
const tooMany = MAX_ERRORS_IN_THROWN_MESSAGE + 7;
const outcomes: ReadonlyArray<StepOutcome<unknown>> = [
{
name: 'emails',
status: 'ok',
durationMs: 1,
result: {
fetched: tooMany,
created: 0,
updated: 0,
errors: Array.from({ length: tooMany }, (_, i) => `err-${i}`),
},
value: undefined,
},
];
let caught: Error | undefined;
try {
reportAndThrowIfErrors(outcomes);
} catch (error) {
caught = error as Error;
}
expect(caught).toBeInstanceOf(Error);
expect(caught?.message).toContain(`Sync completed with ${tooMany} error`);
expect(caught?.message).toContain('...and 7 more');
const renderedErrorLines = caught?.message
.split('\n')
.filter((line) => line.startsWith(' - ')) ?? [];
expect(renderedErrorLines).toHaveLength(MAX_ERRORS_IN_THROWN_MESSAGE);
});
});

View file

@ -0,0 +1,45 @@
import { CoreApiClient } from 'twenty-client-sdk/core';
import { defineLogicFunction } from 'twenty-sdk';
import { SYNC_RESEND_DATA_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER } from 'src/modules/resend/constants/universal-identifiers';
import { getResendClient } from 'src/modules/resend/shared/utils/get-resend-client';
import { logStepOutcome } from 'src/modules/resend/sync/utils/log-step-outcome';
import { orchestrateSyncResend } from 'src/modules/resend/sync/utils/orchestrate-sync-resend';
import { reportAndThrowIfErrors } from 'src/modules/resend/sync/utils/report-and-throw-if-errors';
import { syncBroadcasts } from 'src/modules/resend/sync/utils/sync-broadcasts';
import { syncContacts } from 'src/modules/resend/sync/utils/sync-contacts';
import { syncEmails } from 'src/modules/resend/sync/utils/sync-emails';
import { syncSegments } from 'src/modules/resend/sync/utils/sync-segments';
import { syncTemplates } from 'src/modules/resend/sync/utils/sync-templates';
const handler = async (): Promise<void> => {
const resend = getResendClient();
const client = new CoreApiClient();
const syncedAt = new Date().toISOString();
const outcomes = await orchestrateSyncResend({
syncSegments: () => syncSegments(resend, client, syncedAt),
syncTemplates: () => syncTemplates(resend, client),
syncContacts: () => syncContacts(resend, client, syncedAt),
syncEmails: () => syncEmails(resend, client, syncedAt),
syncBroadcasts: (segmentMap) => syncBroadcasts(resend, client, segmentMap),
});
for (const outcome of outcomes) {
logStepOutcome(outcome);
}
reportAndThrowIfErrors(outcomes);
};
export default defineLogicFunction({
universalIdentifier: SYNC_RESEND_DATA_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER,
name: 'sync-resend-data',
description:
'Syncs emails, contacts, templates, broadcasts, and segments from Resend every 5 minutes',
timeoutSeconds: 300,
handler,
cronTriggerSettings: {
pattern: '*/5 * * * *',
},
});

View file

@ -0,0 +1,9 @@
import type { EmailsField } from 'src/modules/resend/shared/types/emails-field';
export type ContactDto = {
email: EmailsField;
name: { firstName: string; lastName: string };
unsubscribed: boolean;
createdAt: string;
lastSyncedFromResend: string;
};

View file

@ -0,0 +1,11 @@
import type { EmailsField } from 'src/modules/resend/shared/types/emails-field';
import type { UpdateBroadcastDto } from 'src/modules/resend/sync/types/update-broadcast.dto';
export type CreateBroadcastDto = UpdateBroadcastDto & {
name: string;
subject: string | null;
fromAddress: EmailsField;
replyTo: EmailsField;
previewText: string;
createdAt: string;
};

View file

@ -0,0 +1,8 @@
import type { UpdateEmailDto } from 'src/modules/resend/sync/types/update-email.dto';
export type CreateEmailDto = UpdateEmailDto & {
htmlBody: string;
textBody: string;
createdAt: string;
tags: Array<{ name: string; value: string }> | undefined;
};

View file

@ -0,0 +1,5 @@
import type { UpdateTemplateDto } from 'src/modules/resend/sync/types/update-template.dto';
export type CreateTemplateDto = UpdateTemplateDto & {
createdAt: string;
};

View file

@ -0,0 +1,5 @@
export type SegmentDto = {
name: string;
createdAt: string;
lastSyncedFromResend: string;
};

View file

@ -0,0 +1,21 @@
import type { SyncResult } from 'src/modules/resend/sync/types/sync-result';
export type StepOutcome<TValue = undefined> =
| {
name: string;
status: 'ok';
durationMs: number;
result: SyncResult;
value: TValue;
}
| {
name: string;
status: 'failed';
durationMs: number;
error: string;
}
| {
name: string;
status: 'skipped';
reason: string;
};

View file

@ -0,0 +1,6 @@
export type SyncResult = {
fetched: number;
created: number;
updated: number;
errors: string[];
};

View file

@ -0,0 +1,6 @@
import type { SyncResult } from 'src/modules/resend/sync/types/sync-result';
export type SyncStepResult<TValue = undefined> = {
result: SyncResult;
value: TValue;
};

View file

@ -0,0 +1,6 @@
export type UpdateBroadcastDto = {
status: string;
scheduledAt: string | null;
sentAt: string | null;
segmentId?: string | null;
};

View file

@ -0,0 +1,14 @@
import type { EmailsField } from 'src/modules/resend/shared/types/emails-field';
import type { LastEvent } from 'src/modules/resend/shared/utils/map-last-event';
export type UpdateEmailDto = {
subject: string;
fromAddress: EmailsField;
toAddresses: EmailsField;
ccAddresses: EmailsField;
bccAddresses: EmailsField;
replyToAddresses: EmailsField;
lastEvent?: LastEvent;
scheduledAt: string | null;
lastSyncedFromResend: string;
};

View file

@ -0,0 +1,14 @@
import type { EmailsField } from 'src/modules/resend/shared/types/emails-field';
export type UpdateTemplateDto = {
name: string;
alias: string;
status: string;
fromAddress: EmailsField;
subject: string;
replyTo: EmailsField;
htmlBody: string;
textBody: string;
resendUpdatedAt: string;
publishedAt: string | null;
};

View file

@ -0,0 +1,17 @@
import type { CoreApiClient } from 'twenty-client-sdk/core';
export type UpsertRecordsOptions<
TListItem,
TDetail = TListItem,
TCreateDto extends Record<string, unknown> = Record<string, unknown>,
TUpdateDto extends Record<string, unknown> = Record<string, unknown>,
> = {
items: TListItem[];
getId: (item: TListItem) => string;
fetchDetail?: (id: string) => Promise<TDetail>;
mapCreateData: (detail: TDetail, item: TListItem) => TCreateDto;
mapUpdateData: (detail: TDetail, item: TListItem) => TUpdateDto;
existingMap: Map<string, string>;
client: CoreApiClient;
objectNameSingular: string;
};

View file

@ -0,0 +1,41 @@
import type { Resend } from 'resend';
import { CoreApiClient } from 'twenty-client-sdk/core';
import { isDefined } from 'twenty-shared/utils';
import { fetchAllPaginated } from 'src/modules/resend/shared/utils/fetch-all-paginated';
import { findRecordByResendId } from 'src/modules/resend/shared/utils/find-record-by-resend-id';
export const findOrCreateResendSegment = async (
resend: Resend,
client: CoreApiClient,
name: string,
): Promise<string> => {
const existingSegments = await fetchAllPaginated(
(params) => resend.segments.list(params),
'segments',
);
for (const candidate of existingSegments.filter(
(segment) => segment.name === name,
)) {
const linkedRecordId = await findRecordByResendId(
client,
'resendSegments',
candidate.id,
);
if (!isDefined(linkedRecordId)) {
return candidate.id;
}
}
const { data, error } = await resend.segments.create({ name });
if (isDefined(error) || !isDefined(data)) {
throw new Error(
`Failed to create Resend segment: ${JSON.stringify(error)}`,
);
}
return data.id;
};

View file

@ -0,0 +1,71 @@
import { CoreApiClient } from 'twenty-client-sdk/core';
import { isDefined } from 'twenty-shared/utils';
const PAGE_SIZE = 100;
type ExistingRecord = {
id: string;
resendId: string;
};
type PageInfo = {
hasNextPage: boolean;
endCursor: string | null;
};
type ConnectionResult = {
pageInfo: PageInfo;
edges: Array<{ node: ExistingRecord }>;
};
export const getExistingRecordsMap = async (
client: CoreApiClient,
objectNamePlural: string,
): Promise<Map<string, string>> => {
const map = new Map<string, string>();
let hasNextPage = true;
let afterCursor: string | undefined;
while (hasNextPage) {
const args: Record<string, unknown> = { first: PAGE_SIZE };
if (isDefined(afterCursor)) {
args.after = afterCursor;
}
const result = await client.query({
[objectNamePlural]: {
__args: args,
pageInfo: {
hasNextPage: true,
endCursor: true,
},
edges: {
node: {
id: true,
resendId: true,
},
},
},
});
const connection = (result as Record<string, unknown>)[objectNamePlural] as
| ConnectionResult
| undefined;
const edges = connection?.edges ?? [];
for (const edge of edges) {
if (isDefined(edge.node.resendId)) {
map.set(edge.node.resendId, edge.node.id);
}
}
hasNextPage = connection?.pageInfo.hasNextPage ?? false;
afterCursor = connection?.pageInfo.endCursor ?? undefined;
}
return map;
};

View file

@ -0,0 +1,27 @@
import type { StepOutcome } from 'src/modules/resend/sync/types/step-outcome';
export const logStepOutcome = (outcome: StepOutcome<unknown>): void => {
if (outcome.status === 'ok') {
const { name, durationMs, result } = outcome;
console.log(
`[sync] ${name}: ok in ${durationMs}ms — fetched=${result.fetched} created=${result.created} updated=${result.updated} errors=${result.errors.length}`,
);
for (const error of result.errors) {
console.error(`[sync] ${name} error: ${error}`);
}
return;
}
if (outcome.status === 'failed') {
console.error(
`[sync] ${outcome.name}: failed in ${outcome.durationMs}ms — ${outcome.error}`,
);
return;
}
console.warn(`[sync] ${outcome.name}: skipped — ${outcome.reason}`);
};

View file

@ -0,0 +1,33 @@
import type { StepOutcome } from 'src/modules/resend/sync/types/step-outcome';
import type { SyncStepResult } from 'src/modules/resend/sync/types/sync-step-result';
import {
runSyncStep,
skipDueToFailedDeps,
} from 'src/modules/resend/sync/utils/run-sync-step';
import type { SegmentIdMap } from 'src/modules/resend/sync/utils/sync-segments';
export type SyncResendDeps = {
syncSegments: () => Promise<SyncStepResult<SegmentIdMap>>;
syncTemplates: () => Promise<SyncStepResult>;
syncContacts: () => Promise<SyncStepResult>;
syncEmails: () => Promise<SyncStepResult>;
syncBroadcasts: (segmentMap: SegmentIdMap) => Promise<SyncStepResult>;
};
export const orchestrateSyncResend = async (
deps: SyncResendDeps,
): Promise<ReadonlyArray<StepOutcome<unknown>>> => {
const [segments, templates, contacts, emails] = await Promise.all([
runSyncStep('segments', deps.syncSegments),
runSyncStep('templates', deps.syncTemplates),
runSyncStep('contacts', deps.syncContacts),
runSyncStep('emails', deps.syncEmails),
]);
const broadcasts =
segments.status === 'ok'
? await runSyncStep('broadcasts', () => deps.syncBroadcasts(segments.value))
: skipDueToFailedDeps('broadcasts', { segments });
return [segments, templates, contacts, emails, broadcasts];
};

View file

@ -0,0 +1,52 @@
import type { StepOutcome } from 'src/modules/resend/sync/types/step-outcome';
export const MAX_ERRORS_IN_THROWN_MESSAGE = 20;
type AggregatedError = {
step: string;
message: string;
};
const collectErrors = (
outcomes: ReadonlyArray<StepOutcome<unknown>>,
): AggregatedError[] => {
const errors: AggregatedError[] = [];
for (const outcome of outcomes) {
if (outcome.status === 'failed') {
errors.push({ step: outcome.name, message: outcome.error });
continue;
}
if (outcome.status === 'ok') {
for (const error of outcome.result.errors) {
errors.push({ step: outcome.name, message: error });
}
}
}
return errors;
};
export const reportAndThrowIfErrors = (
outcomes: ReadonlyArray<StepOutcome<unknown>>,
): void => {
const errors = collectErrors(outcomes);
if (errors.length === 0) {
return;
}
const head = errors
.slice(0, MAX_ERRORS_IN_THROWN_MESSAGE)
.map(({ step, message }) => ` - [${step}] ${message}`)
.join('\n');
const remaining = errors.length - MAX_ERRORS_IN_THROWN_MESSAGE;
const suffix = remaining > 0 ? `\n ...and ${remaining} more` : '';
throw new Error(
`Sync completed with ${errors.length} error(s):\n${head}${suffix}`,
);
};

View file

@ -0,0 +1,44 @@
import type { StepOutcome } from 'src/modules/resend/sync/types/step-outcome';
import type { SyncStepResult } from 'src/modules/resend/sync/types/sync-step-result';
import { getErrorMessage } from 'src/modules/resend/shared/utils/get-error-message';
export const runSyncStep = async <TValue>(
name: string,
fn: () => Promise<SyncStepResult<TValue>>,
): Promise<StepOutcome<TValue>> => {
const startedAt = performance.now();
try {
const { result, value } = await fn();
return {
name,
status: 'ok',
durationMs: Math.round(performance.now() - startedAt),
result,
value,
};
} catch (error) {
return {
name,
status: 'failed',
durationMs: Math.round(performance.now() - startedAt),
error: getErrorMessage(error),
};
}
};
export const skipDueToFailedDeps = (
name: string,
deps: Record<string, StepOutcome<unknown>>,
): StepOutcome<never> => {
const failed = Object.entries(deps)
.filter(([, outcome]) => outcome.status !== 'ok')
.map(([depName]) => depName);
return {
name,
status: 'skipped',
reason: `prerequisite step(s) did not complete successfully: ${failed.join(', ')}`,
};
};

View file

@ -0,0 +1,96 @@
import type { Resend } from 'resend';
import { CoreApiClient } from 'twenty-client-sdk/core';
import { isDefined } from 'twenty-shared/utils';
import type { CreateBroadcastDto } from 'src/modules/resend/sync/types/create-broadcast.dto';
import type { SyncStepResult } from 'src/modules/resend/sync/types/sync-step-result';
import type { UpdateBroadcastDto } from 'src/modules/resend/sync/types/update-broadcast.dto';
import { fetchAllPaginated } from 'src/modules/resend/shared/utils/fetch-all-paginated';
import { getExistingRecordsMap } from 'src/modules/resend/sync/utils/get-existing-records-map';
import type { SegmentIdMap } from 'src/modules/resend/sync/utils/sync-segments';
import { toEmailsField } from 'src/modules/resend/shared/utils/to-emails-field';
import {
toIsoString,
toIsoStringOrNull,
} from 'src/modules/resend/shared/utils/to-iso-string';
import { upsertRecords } from 'src/modules/resend/sync/utils/upsert-records';
export const syncBroadcasts = async (
resend: Resend,
client: CoreApiClient,
segmentMap: SegmentIdMap,
): Promise<SyncStepResult> => {
const broadcasts = await fetchAllPaginated(
(params) => resend.broadcasts.list(params),
'broadcasts',
);
const existingMap = await getExistingRecordsMap(client, 'resendBroadcasts');
const result = await upsertRecords({
items: broadcasts,
getId: (broadcast) => broadcast.id,
fetchDetail: async (id) => {
const { data: detail, error } = await resend.broadcasts.get(id);
if (isDefined(error) || !isDefined(detail)) {
throw new Error(
`Failed to fetch broadcast ${id}: ${JSON.stringify(error)}`,
);
}
return detail;
},
mapCreateData: (detail, broadcast): CreateBroadcastDto => {
const segmentId = isDefined(broadcast.segment_id)
? segmentMap.get(broadcast.segment_id)
: undefined;
const data: CreateBroadcastDto = {
name: detail.name,
subject: detail.subject,
fromAddress: toEmailsField(detail.from),
replyTo: toEmailsField(detail.reply_to),
previewText: detail.preview_text ?? '',
status: detail.status.toUpperCase(),
createdAt: toIsoString(detail.created_at),
scheduledAt: toIsoStringOrNull(detail.scheduled_at),
sentAt: toIsoStringOrNull(detail.sent_at),
};
if (isDefined(segmentId)) {
data.segmentId = segmentId;
}
return data;
},
mapUpdateData: (_detail, broadcast): UpdateBroadcastDto => {
const data: UpdateBroadcastDto = {
status: broadcast.status.toUpperCase(),
scheduledAt: toIsoStringOrNull(broadcast.scheduled_at),
sentAt: toIsoStringOrNull(broadcast.sent_at),
};
if (!isDefined(broadcast.segment_id)) {
data.segmentId = null;
} else {
const segmentId = segmentMap.get(broadcast.segment_id);
if (isDefined(segmentId)) {
data.segmentId = segmentId;
} else {
console.warn(
`[sync] broadcast ${broadcast.id}: segment ${broadcast.segment_id} not found in lookup map; leaving segmentId untouched`,
);
}
}
return data;
},
existingMap,
client,
objectNameSingular: 'resendBroadcast',
});
return { result, value: undefined };
};

View file

@ -0,0 +1,79 @@
import type { Resend } from 'resend';
import { CoreApiClient } from 'twenty-client-sdk/core';
import { isDefined } from 'twenty-shared/utils';
import type { ContactDto } from 'src/modules/resend/sync/types/contact.dto';
import type { SyncStepResult } from 'src/modules/resend/sync/types/sync-step-result';
import { fetchAllPaginated } from 'src/modules/resend/shared/utils/fetch-all-paginated';
import { findOrCreatePerson } from 'src/modules/resend/shared/utils/find-or-create-person';
import { getErrorMessage } from 'src/modules/resend/shared/utils/get-error-message';
import { getExistingRecordsMap } from 'src/modules/resend/sync/utils/get-existing-records-map';
import { toEmailsField } from 'src/modules/resend/shared/utils/to-emails-field';
import { toIsoString } from 'src/modules/resend/shared/utils/to-iso-string';
import { upsertRecords } from 'src/modules/resend/sync/utils/upsert-records';
export const syncContacts = async (
resend: Resend,
client: CoreApiClient,
syncedAt: string,
): Promise<SyncStepResult> => {
const contacts = await fetchAllPaginated(
(params) => resend.contacts.list(params),
'contacts',
);
const existingMap = await getExistingRecordsMap(client, 'resendContacts');
const mapData = (contact: (typeof contacts)[number]): ContactDto => ({
email: toEmailsField(contact.email),
name: {
firstName: contact.first_name ?? '',
lastName: contact.last_name ?? '',
},
unsubscribed: contact.unsubscribed,
createdAt: toIsoString(contact.created_at),
lastSyncedFromResend: syncedAt,
});
const result = await upsertRecords({
items: contacts,
getId: (contact) => contact.id,
mapCreateData: (_detail, item) => mapData(item),
mapUpdateData: (_detail, item) => mapData(item),
existingMap,
client,
objectNameSingular: 'resendContact',
});
for (const contact of contacts) {
const twentyId = existingMap.get(contact.id);
if (!isDefined(twentyId)) {
continue;
}
try {
const personId = await findOrCreatePerson(client, contact.email, {
firstName: contact.first_name ?? '',
lastName: contact.last_name ?? '',
});
if (isDefined(personId)) {
await client.mutation({
updateResendContact: {
__args: { id: twentyId, data: { personId } },
id: true,
},
});
}
} catch (error) {
const message = getErrorMessage(error);
result.errors.push(
`resendContact ${contact.id} person link: ${message}`,
);
}
}
return { result, value: undefined };
};

View file

@ -0,0 +1,113 @@
import type { Resend } from 'resend';
import { CoreApiClient } from 'twenty-client-sdk/core';
import { isDefined } from 'twenty-shared/utils';
import type { CreateEmailDto } from 'src/modules/resend/sync/types/create-email.dto';
import type { SyncStepResult } from 'src/modules/resend/sync/types/sync-step-result';
import type { UpdateEmailDto } from 'src/modules/resend/sync/types/update-email.dto';
import { fetchAllPaginated } from 'src/modules/resend/shared/utils/fetch-all-paginated';
import { findOrCreatePerson } from 'src/modules/resend/shared/utils/find-or-create-person';
import { getErrorMessage } from 'src/modules/resend/shared/utils/get-error-message';
import { getExistingRecordsMap } from 'src/modules/resend/sync/utils/get-existing-records-map';
import { mapLastEvent } from 'src/modules/resend/shared/utils/map-last-event';
import { toEmailsField } from 'src/modules/resend/shared/utils/to-emails-field';
import {
toIsoString,
toIsoStringOrNull,
} from 'src/modules/resend/shared/utils/to-iso-string';
import { upsertRecords } from 'src/modules/resend/sync/utils/upsert-records';
export const syncEmails = async (
resend: Resend,
client: CoreApiClient,
syncedAt: string,
): Promise<SyncStepResult> => {
const emails = await fetchAllPaginated(
(params) => resend.emails.list(params),
'emails',
);
const existingMap = await getExistingRecordsMap(client, 'resendEmails');
const result = await upsertRecords({
items: emails,
getId: (email) => email.id,
fetchDetail: async (id) => {
const { data: detail, error } = await resend.emails.get(id);
if (isDefined(error) || !isDefined(detail)) {
throw new Error(
`Failed to fetch email ${id}: ${JSON.stringify(error)}`,
);
}
return detail;
},
mapCreateData: (detail): CreateEmailDto => {
const mappedLastEvent = mapLastEvent(detail.last_event);
return {
subject: detail.subject,
fromAddress: toEmailsField(detail.from),
toAddresses: toEmailsField(detail.to),
htmlBody: detail.html ?? '',
textBody: detail.text ?? '',
ccAddresses: toEmailsField(detail.cc),
bccAddresses: toEmailsField(detail.bcc),
replyToAddresses: toEmailsField(detail.reply_to),
...(isDefined(mappedLastEvent) && { lastEvent: mappedLastEvent }),
createdAt: toIsoString(detail.created_at),
scheduledAt: toIsoStringOrNull(detail.scheduled_at),
tags: detail.tags,
lastSyncedFromResend: syncedAt,
};
},
mapUpdateData: (_detail, email): UpdateEmailDto => {
const mappedLastEvent = mapLastEvent(email.last_event);
return {
subject: email.subject,
fromAddress: toEmailsField(email.from),
toAddresses: toEmailsField(email.to),
ccAddresses: toEmailsField(email.cc),
bccAddresses: toEmailsField(email.bcc),
replyToAddresses: toEmailsField(email.reply_to),
...(isDefined(mappedLastEvent) && { lastEvent: mappedLastEvent }),
scheduledAt: toIsoStringOrNull(email.scheduled_at),
lastSyncedFromResend: syncedAt,
};
},
existingMap,
client,
objectNameSingular: 'resendEmail',
});
for (const email of emails) {
const twentyId = existingMap.get(email.id);
if (!isDefined(twentyId)) {
continue;
}
const primaryTo = Array.isArray(email.to) ? email.to[0] : email.to;
try {
const personId = await findOrCreatePerson(client, primaryTo);
if (isDefined(personId)) {
await client.mutation({
updateResendEmail: {
__args: { id: twentyId, data: { personId } },
id: true,
},
});
}
} catch (error) {
const message = getErrorMessage(error);
result.errors.push(`resendEmail ${email.id} person link: ${message}`);
}
}
return { result, value: undefined };
};

View file

@ -0,0 +1,42 @@
import type { Resend } from 'resend';
import { CoreApiClient } from 'twenty-client-sdk/core';
import type { SegmentDto } from 'src/modules/resend/sync/types/segment.dto';
import type { SyncStepResult } from 'src/modules/resend/sync/types/sync-step-result';
import { fetchAllPaginated } from 'src/modules/resend/shared/utils/fetch-all-paginated';
import { getExistingRecordsMap } from 'src/modules/resend/sync/utils/get-existing-records-map';
import { toIsoString } from 'src/modules/resend/shared/utils/to-iso-string';
import { upsertRecords } from 'src/modules/resend/sync/utils/upsert-records';
export type SegmentIdMap = Map<string, string>;
export const syncSegments = async (
resend: Resend,
client: CoreApiClient,
syncedAt: string,
): Promise<SyncStepResult<SegmentIdMap>> => {
const segments = await fetchAllPaginated(
(params) => resend.segments.list(params),
'segments',
);
const existingMap = await getExistingRecordsMap(client, 'resendSegments');
const mapData = (segment: (typeof segments)[number]): SegmentDto => ({
name: segment.name,
createdAt: toIsoString(segment.created_at),
lastSyncedFromResend: syncedAt,
});
const result = await upsertRecords({
items: segments,
getId: (segment) => segment.id,
mapCreateData: (_detail, item) => mapData(item),
mapUpdateData: (_detail, item) => mapData(item),
existingMap,
client,
objectNameSingular: 'resendSegment',
});
return { result, value: existingMap };
};

View file

@ -0,0 +1,90 @@
import type { Resend } from 'resend';
import { CoreApiClient } from 'twenty-client-sdk/core';
import { isDefined } from 'twenty-shared/utils';
import type { CreateTemplateDto } from 'src/modules/resend/sync/types/create-template.dto';
import type { SyncStepResult } from 'src/modules/resend/sync/types/sync-step-result';
import type { UpdateTemplateDto } from 'src/modules/resend/sync/types/update-template.dto';
import { fetchAllPaginated } from 'src/modules/resend/shared/utils/fetch-all-paginated';
import { getExistingRecordsMap } from 'src/modules/resend/sync/utils/get-existing-records-map';
import { toEmailsField } from 'src/modules/resend/shared/utils/to-emails-field';
import {
toIsoString,
toIsoStringOrNull,
} from 'src/modules/resend/shared/utils/to-iso-string';
import { upsertRecords } from 'src/modules/resend/sync/utils/upsert-records';
import { withRateLimitRetry } from 'src/modules/resend/shared/utils/with-rate-limit-retry';
export const syncTemplates = async (
resend: Resend,
client: CoreApiClient,
): Promise<SyncStepResult> => {
const templates = await fetchAllPaginated(
(params) => resend.templates.list(params),
'templates',
);
const existingMap = await getExistingRecordsMap(client, 'resendTemplates');
const detailsMap = new Map<
string,
NonNullable<Awaited<ReturnType<typeof resend.templates.get>>['data']>
>();
for (const template of templates) {
const { data: detail, error } = await withRateLimitRetry(() =>
resend.templates.get(template.id),
);
if (isDefined(error) || !isDefined(detail)) {
throw new Error(
`Failed to fetch template ${template.id}: ${JSON.stringify(error)}`,
);
}
detailsMap.set(template.id, detail);
}
const result = await upsertRecords({
items: templates,
getId: (template) => template.id,
fetchDetail: async (id) => {
const detail = detailsMap.get(id);
if (!isDefined(detail)) {
throw new Error(`Template detail for ${id} not found in cache`);
}
return detail;
},
mapCreateData: (detail): CreateTemplateDto => ({
name: detail.name,
alias: detail.alias ?? '',
status: detail.status.toUpperCase(),
fromAddress: toEmailsField(detail.from),
subject: detail.subject ?? '',
replyTo: toEmailsField(detail.reply_to),
htmlBody: detail.html ?? '',
textBody: detail.text ?? '',
createdAt: toIsoString(detail.created_at),
resendUpdatedAt: toIsoString(detail.updated_at),
publishedAt: toIsoStringOrNull(detail.published_at),
}),
mapUpdateData: (detail, template): UpdateTemplateDto => ({
name: template.name,
alias: template.alias ?? '',
status: template.status.toUpperCase(),
fromAddress: toEmailsField(detail.from),
subject: detail.subject ?? '',
replyTo: toEmailsField(detail.reply_to),
htmlBody: detail.html ?? '',
textBody: detail.text ?? '',
resendUpdatedAt: toIsoString(template.updated_at),
publishedAt: toIsoStringOrNull(template.published_at),
}),
existingMap,
client,
objectNameSingular: 'resendTemplate',
});
return { result, value: undefined };
};

View file

@ -0,0 +1,50 @@
import { CoreApiClient } from 'twenty-client-sdk/core';
import { isDefined } from 'twenty-shared/utils';
import { capitalize } from 'src/modules/resend/shared/utils/capitalize';
export const upsertRecord = async (
client: CoreApiClient,
objectNameSingular: string,
existingMap: Map<string, string>,
resendId: string,
data: Record<string, unknown>,
): Promise<'created' | 'updated'> => {
const existingTwentyId = existingMap.get(resendId);
const createMutationName = `create${capitalize(objectNameSingular)}`;
const updateMutationName = `update${capitalize(objectNameSingular)}`;
if (isDefined(existingTwentyId)) {
await client.mutation({
[updateMutationName]: {
__args: {
id: existingTwentyId,
data,
},
id: true,
},
});
return 'updated';
}
const createResult = await client.mutation({
[createMutationName]: {
__args: {
data: { ...data, resendId },
},
id: true,
},
});
const created = (createResult as Record<string, unknown>)[
createMutationName
] as { id: string } | undefined;
if (isDefined(created)) {
existingMap.set(resendId, created.id);
}
return 'created';
};

View file

@ -0,0 +1,72 @@
import type { SyncResult } from 'src/modules/resend/sync/types/sync-result';
import type { UpsertRecordsOptions } from 'src/modules/resend/sync/types/upsert-records-options';
import { getErrorMessage } from 'src/modules/resend/shared/utils/get-error-message';
import { upsertRecord } from 'src/modules/resend/sync/utils/upsert-record';
import { withRateLimitRetry } from 'src/modules/resend/shared/utils/with-rate-limit-retry';
export const upsertRecords = async <
TListItem,
TDetail = TListItem,
TCreateDto extends Record<string, unknown> = Record<string, unknown>,
TUpdateDto extends Record<string, unknown> = Record<string, unknown>,
>(
options: UpsertRecordsOptions<TListItem, TDetail, TCreateDto, TUpdateDto>,
): Promise<SyncResult> => {
const {
items,
getId,
fetchDetail,
mapCreateData,
mapUpdateData,
existingMap,
client,
objectNameSingular,
} = options;
const result: SyncResult = {
fetched: items.length,
created: 0,
updated: 0,
errors: [],
};
for (const item of items) {
const resendId = getId(item);
try {
const isNew = !existingMap.has(resendId);
const detail = fetchDetail
? await withRateLimitRetry(() => fetchDetail(resendId))
: (item as unknown as TDetail);
if (isNew) {
const data = mapCreateData(detail, item);
await upsertRecord(
client,
objectNameSingular,
existingMap,
resendId,
data,
);
result.created++;
} else {
const data = mapUpdateData(detail, item);
await upsertRecord(
client,
objectNameSingular,
existingMap,
resendId,
data,
);
result.updated++;
}
} catch (error) {
const message = getErrorMessage(error);
result.errors.push(`${objectNameSingular} ${resendId}: ${message}`);
}
}
return result;
};

View file

@ -0,0 +1,273 @@
import { isNonEmptyString } from '@sniptt/guards';
import { CoreApiClient } from 'twenty-client-sdk/core';
import { defineLogicFunction, type RoutePayload } from 'twenty-sdk';
import { isDefined } from 'twenty-shared/utils';
import { RESEND_WEBHOOK_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER } from 'src/modules/resend/constants/universal-identifiers';
import type { WebhookHandlerResult } from 'src/modules/resend/webhooks/types/webhook-handler-result';
import { findOrCreatePerson } from 'src/modules/resend/shared/utils/find-or-create-person';
import { findRecordByResendId } from 'src/modules/resend/shared/utils/find-record-by-resend-id';
import { getResendClient } from 'src/modules/resend/shared/utils/get-resend-client';
import { mapLastEvent } from 'src/modules/resend/shared/utils/map-last-event';
import { toEmailsField } from 'src/modules/resend/shared/utils/to-emails-field';
type ContactEventData = {
id: string;
email: string;
first_name?: string;
last_name?: string;
unsubscribed: boolean;
segment_ids: string[];
created_at: string;
updated_at: string;
};
type BaseEmailEventData = {
email_id: string;
from: string;
to: string[];
subject: string;
created_at: string;
broadcast_id?: string;
template_id?: string;
};
type WebhookPayload = {
type: string;
created_at: string;
data: ContactEventData | BaseEmailEventData | Record<string, unknown>;
};
const handleContactCreatedOrUpdated = async (
client: CoreApiClient,
data: ContactEventData,
): Promise<WebhookHandlerResult> => {
const existingId = await findRecordByResendId(
client,
'resendContacts',
data.id,
);
const personId = await findOrCreatePerson(client, data.email, {
firstName: data.first_name ?? '',
lastName: data.last_name ?? '',
});
const contactData: Record<string, unknown> = {
email: toEmailsField(data.email),
name: {
firstName: data.first_name ?? '',
lastName: data.last_name ?? '',
},
unsubscribed: data.unsubscribed,
lastSyncedFromResend: new Date().toISOString(),
...(isDefined(personId) && { personId }),
};
if (isDefined(existingId)) {
await client.mutation({
updateResendContact: {
__args: { id: existingId, data: contactData },
id: true,
},
});
return { action: 'updated', twentyId: existingId, resendId: data.id, personId };
}
const createResult = await client.mutation({
createResendContact: {
__args: {
data: {
...contactData,
resendId: data.id,
createdAt: data.created_at,
},
},
id: true,
},
});
return {
action: 'created',
twentyId: createResult.createResendContact?.id,
resendId: data.id,
personId,
};
};
const handleContactDeleted = async (
client: CoreApiClient,
data: ContactEventData,
): Promise<WebhookHandlerResult> => {
const existingId = await findRecordByResendId(
client,
'resendContacts',
data.id,
);
if (!isDefined(existingId)) {
return { skipped: true, reason: 'contact not found in Twenty' };
}
await client.mutation({
deleteResendContact: {
__args: { id: existingId },
id: true,
},
});
return { action: 'deleted', twentyId: existingId, resendId: data.id };
};
const handleEmailEvent = async (
client: CoreApiClient,
eventType: string,
data: BaseEmailEventData,
): Promise<WebhookHandlerResult> => {
const existingId = await findRecordByResendId(
client,
'resendEmails',
data.email_id,
);
if (!isDefined(existingId)) {
return {
skipped: true,
reason: `email ${data.email_id} not found in Twenty`,
};
}
const lastEvent = mapLastEvent(eventType);
if (!isDefined(lastEvent)) {
return {
skipped: true,
reason: `unknown email event type: ${eventType}`,
};
}
await client.mutation({
updateResendEmail: {
__args: {
id: existingId,
data: {
lastEvent,
lastSyncedFromResend: new Date().toISOString(),
},
},
id: true,
},
});
return {
action: 'updated',
twentyId: existingId,
resendId: data.email_id,
lastEvent,
};
};
const handler = async (
params: RoutePayload<WebhookPayload>,
): Promise<WebhookHandlerResult> => {
const webhookSecret = process.env.RESEND_WEBHOOK_SECRET;
if (!isNonEmptyString(webhookSecret)) {
throw new Error('RESEND_WEBHOOK_SECRET environment variable is not set');
}
const svixId = params.headers['svix-id'];
const svixTimestamp = params.headers['svix-timestamp'];
const svixSignature = params.headers['svix-signature'];
if (
!isDefined(svixId) ||
!isDefined(svixTimestamp) ||
!isDefined(svixSignature)
) {
return { error: 'Missing webhook signature headers' };
}
const resend = getResendClient();
let event;
try {
event = resend.webhooks.verify({
payload: JSON.stringify(params.body),
headers: {
id: svixId,
timestamp: svixTimestamp,
signature: svixSignature,
},
webhookSecret,
});
} catch (error) {
console.error('[webhook] Resend signature verification failed', error);
return { error: 'Invalid webhook signature' };
}
const client = new CoreApiClient();
const eventType = event.type;
switch (eventType) {
case 'contact.created':
case 'contact.updated':
return handleContactCreatedOrUpdated(
client,
event.data as ContactEventData,
);
case 'contact.deleted':
return handleContactDeleted(client, event.data as ContactEventData);
case 'email.sent':
case 'email.scheduled':
case 'email.delivered':
case 'email.delivery_delayed':
case 'email.complained':
case 'email.bounced':
case 'email.opened':
case 'email.clicked':
case 'email.received':
case 'email.failed':
case 'email.suppressed':
return handleEmailEvent(
client,
eventType,
event.data as BaseEmailEventData,
);
case 'domain.created':
case 'domain.updated':
case 'domain.deleted':
console.log(`[webhook] Domain event ${eventType} received, skipping`);
return { skipped: true, reason: 'domain events not yet handled' };
default:
console.log(`[webhook] Unknown event type: ${eventType}`);
return { skipped: true, reason: `unknown event type: ${eventType}` };
}
};
export default defineLogicFunction({
universalIdentifier: RESEND_WEBHOOK_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER,
name: 'resend-webhook',
description:
'Receives Resend webhook events for real-time inbound sync of contacts and email delivery status',
timeoutSeconds: 30,
handler,
httpRouteTriggerSettings: {
path: '/webhook/resend',
httpMethod: 'POST',
isAuthRequired: false,
forwardedRequestHeaders: [
'svix-id',
'svix-timestamp',
'svix-signature',
],
},
});

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