Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion apps/webapp/app/bullmq/connection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ export function getRedisConnection() {

const redisConfig: RedisOptions = {
host: process.env.REDIS_HOST,
port: parseInt(process.env.REDIS_PORT as string),
port: parseInt(process.env.REDIS_PORT || '6379', 10),
password: process.env.REDIS_PASSWORD,
maxRetriesPerRequest: null, // Required for BullMQ
enableReadyCheck: false, // Required for BullMQ
Expand Down
3 changes: 1 addition & 2 deletions integrations/gmail/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
"build:cli": "rimraf bin && bun build src/index.ts --outfile bin/index.js --target node --minify",
"build:frontend": "tsup",
"dev:frontend": "tsup --watch",
"test": "bun test",
"lint": "eslint --ext js,ts,tsx src/ --fix",
"prettier": "prettier --config .prettierrc --write ."
},
Expand All @@ -31,7 +32,6 @@
"react": "^19.2.4",
"tsup": "^8.0.1",
"@types/nodemailer": "^6.4.17",
"@types/turndown": "^5.0.5",
"@babel/preset-typescript": "^7.26.0",
"@rollup/plugin-commonjs": "^28.0.1",
"@rollup/plugin-json": "^6.1.0",
Expand Down Expand Up @@ -81,7 +81,6 @@
"openai": "^4.0.0",
"react-query": "^3.39.3",
"@redplanethq/sdk": "0.1.18",
"turndown": "^7.2.0",
"@redplanethq/ui": "2.1.4"
}
}
138 changes: 138 additions & 0 deletions integrations/gmail/src/__tests__/schedule.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
import { describe, expect, test } from 'bun:test';

import {
formatMetadataText,
getDefaultSyncTime,
getHeader,
GmailEmailMetadata,
toGmailTimestamp,
} from '../schedule-utils';

describe('toGmailTimestamp', () => {
test('converts ISO date to Unix timestamp in seconds', () => {
const iso = '2024-01-15T10:00:00.000Z';
const expected = Math.floor(new Date(iso).getTime() / 1000);
expect(toGmailTimestamp(iso)).toBe(expected);
});

test('returns an integer (floor)', () => {
const ts = toGmailTimestamp('2024-06-01T00:00:00.500Z');
expect(Number.isInteger(ts)).toBe(true);
});
});

describe('getDefaultSyncTime', () => {
test('returns an ISO string approximately 24 hours ago', () => {
const before = Date.now();
const result = getDefaultSyncTime();

const resultMs = new Date(result).getTime();
const expectedMs = before - 24 * 60 * 60 * 1000;

// Within 100 ms of expected
expect(Math.abs(resultMs - expectedMs)).toBeLessThan(100);
});

test('returns a valid ISO string', () => {
const result = getDefaultSyncTime();
expect(new Date(result).toISOString()).toBe(result);
});
});

describe('getHeader', () => {
const headers = [
{ name: 'From', value: 'Alice <alice@example.com>' },
{ name: 'Subject', value: 'Hello' },
{ name: 'To', value: 'Bob <bob@example.com>' },
];

test('finds a header by exact name', () => {
expect(getHeader(headers, 'From')).toBe('Alice <alice@example.com>');
});

test('is case-insensitive', () => {
expect(getHeader(headers, 'subject')).toBe('Hello');
expect(getHeader(headers, 'SUBJECT')).toBe('Hello');
});

test('returns empty string when header not found', () => {
expect(getHeader(headers, 'Cc')).toBe('');
});
});

describe('formatMetadataText', () => {
const baseMeta: GmailEmailMetadata = {
id: 'msg123',
threadId: 'thread456',
subject: 'Hello World',
from: 'Alice <alice@example.com>',
to: 'Bob <bob@example.com>',
date: 'Mon, 15 Jan 2024 10:00:00 +0000',
internalDate: 1705312800000,
snippet: 'This is a short preview...',
labelIds: ['INBOX', 'IMPORTANT'],
sizeEstimate: 4096,
webLink: 'https://mail.google.com/mail/u/0/#inbox/msg123',
};

test('received email uses envelope icon and "Email from" heading', () => {
const text = formatMetadataText(baseMeta, 'received');
expect(text).toContain('📧');
expect(text).toContain('Email from Alice <alice@example.com>');
});

test('sent email uses outbox icon and "Email to" heading', () => {
const text = formatMetadataText(baseMeta, 'sent');
expect(text).toContain('📤');
expect(text).toContain('Email to Bob <bob@example.com>');
});

test('includes all required metadata fields', () => {
const text = formatMetadataText(baseMeta, 'received');
expect(text).toContain('**ID:** msg123');
expect(text).toContain('**Thread ID:** thread456');
expect(text).toContain('**Subject:** Hello World');
expect(text).toContain('**From:** Alice <alice@example.com>');
expect(text).toContain('**To:** Bob <bob@example.com>');
expect(text).toContain('**Date:** Mon, 15 Jan 2024 10:00:00 +0000');
expect(text).toContain('**Internal Date:** 1705312800000');
expect(text).toContain('**Snippet:** This is a short preview...');
expect(text).toContain('**Labels:** INBOX, IMPORTANT');
expect(text).toContain('**Size:** 4096 bytes');
expect(text).toContain('**Link:** https://mail.google.com/mail/u/0/#inbox/msg123');
});

test('includes historyId when present', () => {
const meta = { ...baseMeta, historyId: 'hist789' };
const text = formatMetadataText(meta, 'received');
expect(text).toContain('**History ID:** hist789');
});

test('omits historyId line when not present', () => {
const text = formatMetadataText(baseMeta, 'received');
expect(text).not.toContain('History ID');
});

test('includes Cc when present', () => {
const meta = { ...baseMeta, cc: 'Charlie <charlie@example.com>' };
const text = formatMetadataText(meta, 'received');
expect(text).toContain('**Cc:** Charlie <charlie@example.com>');
});

test('omits Cc line when not present', () => {
const text = formatMetadataText(baseMeta, 'received');
expect(text).not.toContain('**Cc:**');
});

test('includes Bcc when present', () => {
const meta = { ...baseMeta, bcc: 'Dan <dan@example.com>' };
const text = formatMetadataText(meta, 'received');
expect(text).toContain('**Bcc:** Dan <dan@example.com>');
});

test('does not contain HTML body tags', () => {
const text = formatMetadataText(baseMeta, 'received');
// Email addresses use angle brackets which are fine; actual HTML elements should not appear
expect(text).not.toMatch(/<(html|body|div|p|span|br|img|a\s)[^>]*>/i);
});
});
83 changes: 83 additions & 0 deletions integrations/gmail/src/schedule-utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
export interface GmailEmailMetadata {
id: string;
threadId: string;
historyId?: string;
subject: string;
from: string;
to: string;
cc?: string;
bcc?: string;
date: string;
internalDate: number;
snippet: string;
labelIds: string[];
sizeEstimate: number;
webLink: string;
}

/**
* Gets default sync time (24 hours ago).
*/
export function getDefaultSyncTime(): string {
return new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString();
}

/**
* Convert ISO date to Gmail query format as Unix timestamp in seconds.
* Using timestamp is more precise than YYYY/MM/DD which truncates to day.
*/
export function toGmailTimestamp(isoDate: string): number {
return Math.floor(new Date(isoDate).getTime() / 1000);
}

/**
* Format email metadata as structured Markdown text (no body content).
*/
export function formatMetadataText(
meta: GmailEmailMetadata,
direction: 'received' | 'sent'
): string {
const icon = direction === 'received' ? '📧' : '📤';
const label = direction === 'received' ? `Email from ${meta.from}` : `Email to ${meta.to}`;

const lines = [
`## ${icon} ${label}`,
'',
`**ID:** ${meta.id}`,
`**Thread ID:** ${meta.threadId}`,
];

if (meta.historyId) {
lines.push(`**History ID:** ${meta.historyId}`);
}

lines.push(
`**Subject:** ${meta.subject}`,
`**From:** ${meta.from}`,
`**To:** ${meta.to}`
);

if (meta.cc) lines.push(`**Cc:** ${meta.cc}`);
if (meta.bcc) lines.push(`**Bcc:** ${meta.bcc}`);

lines.push(
`**Date:** ${meta.date}`,
`**Internal Date:** ${meta.internalDate}`,
`**Snippet:** ${meta.snippet}`,
`**Labels:** ${meta.labelIds.join(', ')}`,
`**Size:** ${meta.sizeEstimate} bytes`,
`**Link:** ${meta.webLink}`
);

return lines.join('\n');
}

/**
* Extract a header value from a Gmail message headers array.
*/
export function getHeader(
headers: Array<{ name: string; value: string }>,
name: string
): string {
return headers.find((h) => h.name.toLowerCase() === name.toLowerCase())?.value ?? '';
}
Loading