d25d482dc2
Publish CLI Package / publish-npm (push) Waiting to run
Publish Python SDK / publish-pypi (push) Waiting to run
Publish TypeScript SDK / publish-npm (push) Waiting to run
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
576 lines
17 KiB
TypeScript
576 lines
17 KiB
TypeScript
import { createLogger } from '@sim/logger'
|
|
import { getErrorMessage, toError } from '@sim/utils/errors'
|
|
import { fetchWithRetry, VALIDATE_RETRY_OPTIONS } from '@/lib/knowledge/documents/utils'
|
|
import { DEFAULT_MAX_THREADS, gmailConnectorMeta } from '@/connectors/gmail/meta'
|
|
import type { ConnectorConfig, ExternalDocument, ExternalDocumentList } from '@/connectors/types'
|
|
import { htmlToPlainText, joinTagArray, parseMultiValue, parseTagDate } from '@/connectors/utils'
|
|
|
|
const logger = createLogger('GmailConnector')
|
|
|
|
const GMAIL_API_BASE = 'https://gmail.googleapis.com/gmail/v1/users/me'
|
|
const THREADS_PER_PAGE = 100
|
|
|
|
interface GmailHeader {
|
|
name: string
|
|
value: string
|
|
}
|
|
|
|
interface GmailMessagePart {
|
|
mimeType?: string
|
|
body?: { data?: string; size?: number }
|
|
parts?: GmailMessagePart[]
|
|
headers?: GmailHeader[]
|
|
}
|
|
|
|
interface GmailMessage {
|
|
id: string
|
|
threadId: string
|
|
internalDate?: string
|
|
payload?: GmailMessagePart
|
|
labelIds?: string[]
|
|
snippet?: string
|
|
}
|
|
|
|
interface GmailThread {
|
|
id: string
|
|
historyId?: string
|
|
messages?: GmailMessage[]
|
|
snippet?: string
|
|
}
|
|
|
|
interface GmailLabel {
|
|
id: string
|
|
name: string
|
|
type?: string
|
|
}
|
|
|
|
/**
|
|
* Formats a single Gmail label name for use in a `label:` operator.
|
|
* Gmail search syntax accepts quoted strings for labels containing spaces;
|
|
* unquoted label tokens have spaces replaced with hyphens.
|
|
*/
|
|
function formatLabelToken(name: string): string {
|
|
const trimmed = name.trim()
|
|
if (!trimmed) return ''
|
|
if (/\s/.test(trimmed)) {
|
|
const escaped = trimmed.replace(/\\/g, '\\\\').replace(/"/g, '\\"')
|
|
return `label:"${escaped}"`
|
|
}
|
|
return `label:${trimmed}`
|
|
}
|
|
|
|
/**
|
|
* Builds a Gmail search query string from the source config.
|
|
* Combines the user's custom query with the label and date range filters.
|
|
* When multiple labels are provided, they are OR-joined: `(label:A OR label:B)`.
|
|
*/
|
|
function buildSearchQuery(sourceConfig: Record<string, unknown>): string {
|
|
const parts: string[] = []
|
|
|
|
const labelNames = parseMultiValue(sourceConfig.label)
|
|
if (labelNames.length === 1) {
|
|
const token = formatLabelToken(labelNames[0])
|
|
if (token) parts.push(token)
|
|
} else if (labelNames.length > 1) {
|
|
const tokens = labelNames.map(formatLabelToken).filter(Boolean)
|
|
if (tokens.length === 1) {
|
|
parts.push(tokens[0])
|
|
} else if (tokens.length > 1) {
|
|
parts.push(`(${tokens.join(' OR ')})`)
|
|
}
|
|
}
|
|
|
|
const dateRange = (sourceConfig.dateRange as string) || 'all'
|
|
const now = new Date()
|
|
switch (dateRange) {
|
|
case '7d':
|
|
parts.push(`after:${formatGmailDate(daysAgo(now, 7))}`)
|
|
break
|
|
case '30d':
|
|
parts.push(`after:${formatGmailDate(daysAgo(now, 30))}`)
|
|
break
|
|
case '90d':
|
|
parts.push(`after:${formatGmailDate(daysAgo(now, 90))}`)
|
|
break
|
|
case '6m':
|
|
parts.push(`after:${formatGmailDate(daysAgo(now, 180))}`)
|
|
break
|
|
case '1y':
|
|
parts.push(`after:${formatGmailDate(daysAgo(now, 365))}`)
|
|
break
|
|
}
|
|
|
|
const excludePromotions = sourceConfig.excludePromotions !== 'false'
|
|
if (excludePromotions) {
|
|
parts.push('-category:promotions')
|
|
}
|
|
|
|
const excludeSocial = sourceConfig.excludeSocial !== 'false'
|
|
if (excludeSocial) {
|
|
parts.push('-category:social')
|
|
}
|
|
|
|
const customQuery = sourceConfig.query as string | undefined
|
|
const trimmedCustom = customQuery?.trim()
|
|
if (trimmedCustom) {
|
|
/**
|
|
* Wrap the user-supplied query in parentheses whenever it contains an OR
|
|
* so it's AND-joined as a single clause with the preceding label / category
|
|
* / date filters. Always wrap (rather than try to detect existing outer
|
|
* parens) because a regex like /^\(.*\)$/ misclassifies inputs such as
|
|
* `(from:alice) OR (from:bob)` where the parens don't bracket the whole
|
|
* expression. Double-wrapping is a no-op in Gmail search syntax.
|
|
*/
|
|
const needsGroup = /\bOR\b/i.test(trimmedCustom)
|
|
parts.push(needsGroup ? `(${trimmedCustom})` : trimmedCustom)
|
|
}
|
|
|
|
return parts.join(' ')
|
|
}
|
|
|
|
function daysAgo(now: Date, days: number): Date {
|
|
return new Date(now.getTime() - days * 24 * 60 * 60 * 1000)
|
|
}
|
|
|
|
function formatGmailDate(date: Date): string {
|
|
const y = date.getFullYear()
|
|
const m = String(date.getMonth() + 1).padStart(2, '0')
|
|
const d = String(date.getDate()).padStart(2, '0')
|
|
return `${y}/${m}/${d}`
|
|
}
|
|
|
|
/**
|
|
* Decodes base64url-encoded content from the Gmail API.
|
|
* Uses Buffer to correctly handle multi-byte UTF-8 characters.
|
|
*/
|
|
function decodeBase64Url(data: string): string {
|
|
return Buffer.from(data, 'base64url').toString('utf-8')
|
|
}
|
|
|
|
/**
|
|
* Extracts the plain text body from a Gmail message payload.
|
|
* Prefers text/plain, falls back to text/html with tag stripping.
|
|
*/
|
|
function extractBody(part: GmailMessagePart): string {
|
|
if (part.mimeType === 'text/plain' && part.body?.data) {
|
|
return decodeBase64Url(part.body.data)
|
|
}
|
|
|
|
if (part.parts) {
|
|
// Prefer text/plain from multipart
|
|
for (const child of part.parts) {
|
|
if (child.mimeType === 'text/plain' && child.body?.data) {
|
|
return decodeBase64Url(child.body.data)
|
|
}
|
|
}
|
|
// Fall back to text/html
|
|
for (const child of part.parts) {
|
|
if (child.mimeType === 'text/html' && child.body?.data) {
|
|
return htmlToPlainText(decodeBase64Url(child.body.data))
|
|
}
|
|
}
|
|
// Recurse into nested multipart
|
|
for (const child of part.parts) {
|
|
const result = extractBody(child)
|
|
if (result) return result
|
|
}
|
|
}
|
|
|
|
if (part.mimeType === 'text/html' && part.body?.data) {
|
|
return htmlToPlainText(decodeBase64Url(part.body.data))
|
|
}
|
|
|
|
return ''
|
|
}
|
|
|
|
/**
|
|
* Gets a header value from a Gmail message payload.
|
|
*/
|
|
function getHeader(payload: GmailMessagePart | undefined, name: string): string | undefined {
|
|
if (!payload?.headers) return undefined
|
|
const header = payload.headers.find((h) => h.name.toLowerCase() === name.toLowerCase())
|
|
return header?.value
|
|
}
|
|
|
|
/**
|
|
* Formats a thread's messages into a single document string.
|
|
*/
|
|
function formatThread(thread: GmailThread): {
|
|
content: string
|
|
subject: string
|
|
metadata: Record<string, unknown>
|
|
} {
|
|
const messages = thread.messages || []
|
|
if (messages.length === 0) {
|
|
return { content: '', subject: 'Untitled Thread', metadata: {} }
|
|
}
|
|
|
|
const firstMessage = messages[0]
|
|
const lastMessage = messages[messages.length - 1]
|
|
const subject = getHeader(firstMessage.payload, 'Subject') || 'No Subject'
|
|
const from = getHeader(firstMessage.payload, 'From') || 'Unknown'
|
|
const to = getHeader(firstMessage.payload, 'To') || ''
|
|
const labelIds = firstMessage.labelIds || []
|
|
|
|
const lines: string[] = []
|
|
lines.push(`Subject: ${subject}`)
|
|
lines.push(`From: ${from}`)
|
|
if (to) lines.push(`To: ${to}`)
|
|
lines.push(`Messages: ${messages.length}`)
|
|
lines.push('')
|
|
|
|
for (const msg of messages) {
|
|
const msgFrom = getHeader(msg.payload, 'From') || 'Unknown'
|
|
const msgDate = getHeader(msg.payload, 'Date') || ''
|
|
const body = msg.payload ? extractBody(msg.payload) : ''
|
|
|
|
lines.push(`--- ${msgFrom} (${msgDate}) ---`)
|
|
lines.push(body.trim())
|
|
lines.push('')
|
|
}
|
|
|
|
const firstDate = firstMessage.internalDate
|
|
? new Date(Number(firstMessage.internalDate)).toISOString()
|
|
: undefined
|
|
const lastDate = lastMessage.internalDate
|
|
? new Date(Number(lastMessage.internalDate)).toISOString()
|
|
: undefined
|
|
|
|
return {
|
|
content: lines.join('\n').trim(),
|
|
subject,
|
|
metadata: {
|
|
from,
|
|
to,
|
|
subject,
|
|
messageCount: messages.length,
|
|
labelIds,
|
|
firstMessageDate: firstDate,
|
|
lastMessageDate: lastDate,
|
|
},
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Fetches a full thread with all its messages.
|
|
*/
|
|
async function fetchThread(accessToken: string, threadId: string): Promise<GmailThread | null> {
|
|
const url = `${GMAIL_API_BASE}/threads/${threadId}?format=FULL`
|
|
|
|
const response = await fetchWithRetry(url, {
|
|
method: 'GET',
|
|
headers: {
|
|
Authorization: `Bearer ${accessToken}`,
|
|
Accept: 'application/json',
|
|
},
|
|
})
|
|
|
|
if (!response.ok) {
|
|
if (response.status === 404) return null
|
|
throw new Error(`Failed to fetch thread ${threadId}: ${response.status}`)
|
|
}
|
|
|
|
return (await response.json()) as GmailThread
|
|
}
|
|
|
|
/**
|
|
* Resolves label IDs to human-readable label names using a cache.
|
|
*/
|
|
async function resolveLabelNames(
|
|
accessToken: string,
|
|
labelIds: string[],
|
|
syncContext?: Record<string, unknown>
|
|
): Promise<string[]> {
|
|
const cacheKey = '_gmailLabelCache'
|
|
|
|
if (syncContext && !syncContext[cacheKey]) {
|
|
try {
|
|
const url = `${GMAIL_API_BASE}/labels`
|
|
const response = await fetchWithRetry(url, {
|
|
method: 'GET',
|
|
headers: {
|
|
Authorization: `Bearer ${accessToken}`,
|
|
Accept: 'application/json',
|
|
},
|
|
})
|
|
|
|
if (response.ok) {
|
|
const data = await response.json()
|
|
const labels = (data.labels || []) as GmailLabel[]
|
|
const labelMap: Record<string, string> = {}
|
|
for (const label of labels) {
|
|
labelMap[label.id] = label.name
|
|
}
|
|
syncContext[cacheKey] = labelMap
|
|
}
|
|
} catch {
|
|
syncContext[cacheKey] = {}
|
|
}
|
|
}
|
|
|
|
const cache = (syncContext?.[cacheKey] as Record<string, string>) ?? {}
|
|
return labelIds
|
|
.map((id) => cache[id] || id)
|
|
.filter((name) => !name.startsWith('CATEGORY_') && name !== 'UNREAD')
|
|
}
|
|
|
|
/**
|
|
* Creates a lightweight document stub from a thread list entry.
|
|
* Uses metadata-based contentHash for change detection without downloading content.
|
|
*/
|
|
function threadToStub(thread: {
|
|
id: string
|
|
snippet?: string
|
|
historyId?: string
|
|
}): ExternalDocument {
|
|
return {
|
|
externalId: thread.id,
|
|
title: thread.snippet || 'Untitled Thread',
|
|
content: '',
|
|
contentDeferred: true,
|
|
mimeType: 'text/plain',
|
|
sourceUrl: `https://mail.google.com/mail/u/0/#inbox/${thread.id}`,
|
|
contentHash: `gmail:${thread.id}:${thread.historyId ?? ''}`,
|
|
metadata: {},
|
|
}
|
|
}
|
|
|
|
export const gmailConnector: ConnectorConfig = {
|
|
...gmailConnectorMeta,
|
|
|
|
listDocuments: async (
|
|
accessToken: string,
|
|
sourceConfig: Record<string, unknown>,
|
|
cursor?: string,
|
|
syncContext?: Record<string, unknown>
|
|
): Promise<ExternalDocumentList> => {
|
|
const searchQuery = buildSearchQuery(sourceConfig)
|
|
const maxThreads = sourceConfig.maxThreads
|
|
? Number(sourceConfig.maxThreads)
|
|
: DEFAULT_MAX_THREADS
|
|
|
|
const totalFetched = (syncContext?.totalThreadsFetched as number) ?? 0
|
|
if (totalFetched >= maxThreads) {
|
|
return { documents: [], hasMore: false }
|
|
}
|
|
|
|
const remaining = maxThreads - totalFetched
|
|
const pageSize = Math.min(THREADS_PER_PAGE, remaining)
|
|
|
|
const queryParams = new URLSearchParams({
|
|
maxResults: String(pageSize),
|
|
})
|
|
|
|
if (searchQuery) {
|
|
queryParams.set('q', searchQuery)
|
|
}
|
|
|
|
if (cursor) {
|
|
queryParams.set('pageToken', cursor)
|
|
}
|
|
|
|
const url = `${GMAIL_API_BASE}/threads?${queryParams.toString()}`
|
|
|
|
logger.info('Listing Gmail threads', {
|
|
query: searchQuery,
|
|
cursor: cursor ?? 'initial',
|
|
maxThreads,
|
|
})
|
|
|
|
const response = await fetchWithRetry(url, {
|
|
method: 'GET',
|
|
headers: {
|
|
Authorization: `Bearer ${accessToken}`,
|
|
Accept: 'application/json',
|
|
},
|
|
})
|
|
|
|
if (!response.ok) {
|
|
const errorText = await response.text()
|
|
logger.error('Failed to list Gmail threads', { status: response.status, error: errorText })
|
|
throw new Error(`Failed to list Gmail threads: ${response.status}`)
|
|
}
|
|
|
|
const data = await response.json()
|
|
const threads = (data.threads || []) as { id: string; snippet?: string; historyId?: string }[]
|
|
|
|
if (threads.length === 0) {
|
|
return { documents: [], hasMore: false }
|
|
}
|
|
|
|
const documents = threads.map(threadToStub)
|
|
|
|
const newTotal = totalFetched + documents.length
|
|
if (syncContext) syncContext.totalThreadsFetched = newTotal
|
|
|
|
const nextPageToken = data.nextPageToken as string | undefined
|
|
const hitLimit = newTotal >= maxThreads
|
|
if (hitLimit && syncContext) syncContext.listingCapped = true
|
|
|
|
return {
|
|
documents,
|
|
nextCursor: hitLimit ? undefined : nextPageToken,
|
|
hasMore: hitLimit ? false : Boolean(nextPageToken),
|
|
}
|
|
},
|
|
|
|
getDocument: async (
|
|
accessToken: string,
|
|
_sourceConfig: Record<string, unknown>,
|
|
externalId: string,
|
|
syncContext?: Record<string, unknown>
|
|
): Promise<ExternalDocument | null> => {
|
|
try {
|
|
const thread = await fetchThread(accessToken, externalId)
|
|
if (!thread) return null
|
|
|
|
const { content, subject, metadata } = formatThread(thread)
|
|
if (!content.trim()) return null
|
|
|
|
const labelIds = (metadata.labelIds as string[]) || []
|
|
const labelNames = await resolveLabelNames(accessToken, labelIds, syncContext)
|
|
metadata.labels = labelNames
|
|
|
|
return {
|
|
externalId: thread.id,
|
|
title: subject,
|
|
content,
|
|
contentDeferred: false,
|
|
mimeType: 'text/plain',
|
|
sourceUrl: `https://mail.google.com/mail/u/0/#inbox/${thread.id}`,
|
|
contentHash: `gmail:${thread.id}:${thread.historyId ?? ''}`,
|
|
metadata,
|
|
}
|
|
} catch (error) {
|
|
logger.warn('Failed to get Gmail thread', {
|
|
externalId,
|
|
error: toError(error).message,
|
|
})
|
|
return null
|
|
}
|
|
},
|
|
|
|
validateConfig: async (
|
|
accessToken: string,
|
|
sourceConfig: Record<string, unknown>
|
|
): Promise<{ valid: boolean; error?: string }> => {
|
|
const maxThreads = sourceConfig.maxThreads as string | undefined
|
|
|
|
if (maxThreads && (Number.isNaN(Number(maxThreads)) || Number(maxThreads) <= 0)) {
|
|
return { valid: false, error: 'Max threads must be a positive number' }
|
|
}
|
|
|
|
try {
|
|
// Verify Gmail API access by fetching profile
|
|
const profileUrl = `${GMAIL_API_BASE}/profile`
|
|
const profileResponse = await fetchWithRetry(
|
|
profileUrl,
|
|
{
|
|
method: 'GET',
|
|
headers: {
|
|
Authorization: `Bearer ${accessToken}`,
|
|
Accept: 'application/json',
|
|
},
|
|
},
|
|
VALIDATE_RETRY_OPTIONS
|
|
)
|
|
|
|
if (!profileResponse.ok) {
|
|
return { valid: false, error: `Failed to access Gmail: ${profileResponse.status}` }
|
|
}
|
|
|
|
// If labels are specified, verify each one exists
|
|
const labelNames = parseMultiValue(sourceConfig.label)
|
|
if (labelNames.length > 0) {
|
|
const labelsUrl = `${GMAIL_API_BASE}/labels`
|
|
const labelsResponse = await fetchWithRetry(
|
|
labelsUrl,
|
|
{
|
|
method: 'GET',
|
|
headers: {
|
|
Authorization: `Bearer ${accessToken}`,
|
|
Accept: 'application/json',
|
|
},
|
|
},
|
|
VALIDATE_RETRY_OPTIONS
|
|
)
|
|
|
|
if (!labelsResponse.ok) {
|
|
return { valid: false, error: 'Failed to fetch labels' }
|
|
}
|
|
|
|
const labelsData = await labelsResponse.json()
|
|
const labels = (labelsData.labels || []) as GmailLabel[]
|
|
const labelNameSet = new Set(labels.map((l) => l.name.toLowerCase()))
|
|
const missing = labelNames.filter((name) => !labelNameSet.has(name.toLowerCase()))
|
|
|
|
if (missing.length > 0) {
|
|
return {
|
|
valid: false,
|
|
error: `Label(s) not found: ${missing.join(', ')}. Available labels: ${labels
|
|
.filter(
|
|
(l) =>
|
|
l.type !== 'system' ||
|
|
['INBOX', 'IMPORTANT', 'STARRED', 'SENT', 'DRAFT'].includes(l.id)
|
|
)
|
|
.map((l) => l.name)
|
|
.slice(0, 15)
|
|
.join(', ')}`,
|
|
}
|
|
}
|
|
}
|
|
|
|
// If a custom query is specified, verify it's valid by doing a dry-run
|
|
const query = sourceConfig.query as string | undefined
|
|
if (query?.trim()) {
|
|
const searchQuery = buildSearchQuery(sourceConfig)
|
|
const testUrl = `${GMAIL_API_BASE}/threads?q=${encodeURIComponent(searchQuery)}&maxResults=1`
|
|
const testResponse = await fetchWithRetry(
|
|
testUrl,
|
|
{
|
|
method: 'GET',
|
|
headers: {
|
|
Authorization: `Bearer ${accessToken}`,
|
|
Accept: 'application/json',
|
|
},
|
|
},
|
|
VALIDATE_RETRY_OPTIONS
|
|
)
|
|
|
|
if (!testResponse.ok) {
|
|
return { valid: false, error: 'Invalid search query. Check Gmail search syntax.' }
|
|
}
|
|
}
|
|
|
|
return { valid: true }
|
|
} catch (error) {
|
|
const message = getErrorMessage(error, 'Failed to validate configuration')
|
|
return { valid: false, error: message }
|
|
}
|
|
},
|
|
|
|
mapTags: (metadata: Record<string, unknown>): Record<string, unknown> => {
|
|
const result: Record<string, unknown> = {}
|
|
|
|
if (typeof metadata.from === 'string') {
|
|
result.from = metadata.from
|
|
}
|
|
|
|
const labels = joinTagArray(metadata.labels)
|
|
if (labels) {
|
|
result.labels = labels
|
|
}
|
|
|
|
if (typeof metadata.messageCount === 'number') {
|
|
result.messageCount = metadata.messageCount
|
|
}
|
|
|
|
const lastMessageDate = parseTagDate(metadata.lastMessageDate)
|
|
if (lastMessageDate) {
|
|
result.lastMessageDate = lastMessageDate
|
|
}
|
|
|
|
return result
|
|
},
|
|
}
|