diff --git a/lerna.json b/lerna.json
index c2e45f307..e653b59a0 100644
--- a/lerna.json
+++ b/lerna.json
@@ -23,6 +23,7 @@
"packages/provider-venom",
"packages/provider-gohighlevel",
"packages/provider-email",
+ "packages/provider-gmail",
"packages/contexts-dialogflow",
"packages/contexts-dialogflow-cx"
],
diff --git a/package.json b/package.json
index 3cf927a5c..3abef8d39 100644
--- a/package.json
+++ b/package.json
@@ -67,6 +67,7 @@
"packages/provider-venom",
"packages/provider-gohighlevel",
"packages/provider-email",
+ "packages/provider-gmail",
"packages/contexts-dialogflow",
"packages/contexts-dialogflow-cx"
],
diff --git a/packages/cli/src/configuration/index.ts b/packages/cli/src/configuration/index.ts
index 318387f4d..9792bd223 100644
--- a/packages/cli/src/configuration/index.ts
+++ b/packages/cli/src/configuration/index.ts
@@ -29,6 +29,7 @@ export const PROVIDER_LIST: Provider[] = [
{ value: 'instagram', label: 'Instagram' },
{ value: 'gohighlevel', label: 'GoHighLevel' },
{ value: 'email', label: 'Email', hint: 'IMAP/SMTP' },
+ { value: 'gmail', label: 'Gmail', hint: 'OAuth2' },
]
export const PROVIDER_DATA: ValueLabel[] = [
diff --git a/packages/provider-gmail/LICENSE.md b/packages/provider-gmail/LICENSE.md
new file mode 100644
index 000000000..959d8ec5a
--- /dev/null
+++ b/packages/provider-gmail/LICENSE.md
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2022 Leifer Mendez
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/packages/provider-gmail/README.md b/packages/provider-gmail/README.md
new file mode 100644
index 000000000..c5330ea25
--- /dev/null
+++ b/packages/provider-gmail/README.md
@@ -0,0 +1,225 @@
+# @builderbot/provider-gmail
+
+Gmail provider for BuilderBot using the Gmail API with OAuth2 authentication. Receive emails in real-time via polling and send emails through the Gmail API.
+
+## Installation
+
+```bash
+npm install @builderbot/provider-gmail
+# or
+pnpm add @builderbot/provider-gmail
+```
+
+## Features
+
+- Gmail API with OAuth2 authentication (no app passwords needed)
+- Real-time email reception via polling with history tracking
+- Send emails via Gmail API
+- Native Gmail thread support
+- Attachment support (send and receive via Gmail API)
+- Automatic mark-as-read support
+
+## Prerequisites
+
+### Setting up Google Cloud OAuth2
+
+1. Go to [Google Cloud Console](https://console.cloud.google.com/)
+2. Create a new project or select an existing one
+3. Enable the **Gmail API** in the API Library
+4. Go to **Credentials** > **Create Credentials** > **OAuth 2.0 Client ID**
+5. Set the application type to **Web application**
+6. Add `http://localhost:3000` to Authorized redirect URIs
+7. Copy the **Client ID** and **Client Secret**
+
+### Obtaining a Refresh Token
+
+Use the [OAuth 2.0 Playground](https://developers.google.com/oauthplayground/) or implement your own authorization flow:
+
+1. Go to [OAuth 2.0 Playground](https://developers.google.com/oauthplayground/)
+2. Click the gear icon and check "Use your own OAuth credentials"
+3. Enter your Client ID and Client Secret
+4. In Step 1, select `https://mail.google.com/` scope
+5. Click "Authorize APIs" and sign in with your Gmail account
+6. In Step 2, click "Exchange authorization code for tokens"
+7. Copy the **Refresh Token**
+
+## Quick Start
+
+```typescript
+import { createBot, createProvider, createFlow, addKeyword } from '@builderbot/bot'
+import { GmailProvider } from '@builderbot/provider-gmail'
+
+const gmailProvider = createProvider(GmailProvider, {
+ email: 'your-email@gmail.com',
+ oauth2: {
+ clientId: 'YOUR_CLIENT_ID',
+ clientSecret: 'YOUR_CLIENT_SECRET',
+ refreshToken: 'YOUR_REFRESH_TOKEN'
+ }
+})
+
+const welcomeFlow = addKeyword(['hello', 'hi'])
+ .addAnswer('Hello! I received your email.')
+ .addAction(async (ctx, { provider }) => {
+ console.log('Email from:', ctx.from)
+ console.log('Subject:', ctx.subject)
+ console.log('Body:', ctx.body)
+ console.log('Thread ID:', ctx.threadId)
+ console.log('Is reply:', ctx.isReply)
+ })
+
+const main = async () => {
+ await createBot({
+ flow: createFlow([welcomeFlow]),
+ provider: gmailProvider,
+ database: new MemoryDB()
+ })
+}
+
+main()
+```
+
+## Configuration
+
+### IGmailProviderArgs
+
+| Property | Type | Required | Default | Description |
+|----------|------|----------|---------|-------------|
+| `email` | `string` | Yes | - | Gmail account email address |
+| `oauth2` | `GmailOAuth2Config` | Yes | - | OAuth2 credentials |
+| `label` | `string` | No | `'INBOX'` | Gmail label to monitor |
+| `markAsRead` | `boolean` | No | `true` | Mark emails as read after processing |
+| `fromName` | `string` | No | - | Display name for outgoing emails |
+| `messageSource` | `string` | No | `'body'` | Source for message: 'body', 'subject', or 'both' |
+| `pollingInterval` | `number` | No | `10000` | Polling interval in ms |
+
+### GmailOAuth2Config
+
+| Property | Type | Required | Description |
+|----------|------|----------|-------------|
+| `clientId` | `string` | Yes | OAuth2 Client ID |
+| `clientSecret` | `string` | Yes | OAuth2 Client Secret |
+| `refreshToken` | `string` | Yes | OAuth2 Refresh Token |
+
+## Email Context (ctx)
+
+When an email is received, the context object includes:
+
+```typescript
+interface GmailBotContext {
+ from: string // Sender's email address
+ name: string // Sender's display name
+ body: string // Email body (plain text)
+ subject: string // Email subject
+ messageId: string // RFC Message-ID header
+ threadId?: string // Gmail Thread ID
+ inReplyTo?: string // ID of email being replied to
+ isReply: boolean // Whether this is a reply
+ attachments?: Array<{
+ filename: string
+ contentType: string
+ size: number
+ attachmentId?: string
+ }>
+ html?: string // HTML content (if available)
+ to?: string[] // Recipients
+ cc?: string[] // CC recipients
+ date?: Date // Email date
+ gmailId?: string // Gmail internal message ID
+}
+```
+
+## API Methods
+
+### sendMessage(to, message, options?)
+
+Send an email to a recipient.
+
+```typescript
+await provider.sendMessage('recipient@example.com', 'Hello!', {
+ subject: 'Greeting',
+ html: '
Hello!
'
+})
+```
+
+### sendMedia(to, message, mediaPath, options?)
+
+Send an email with an attachment.
+
+```typescript
+await provider.sendMedia(
+ 'recipient@example.com',
+ 'Please find the document attached.',
+ '/path/to/document.pdf',
+ { subject: 'Document' }
+)
+```
+
+### reply(ctx, message, options?)
+
+Reply to an existing email thread.
+
+```typescript
+.addAction(async (ctx, { provider }) => {
+ await provider.reply(ctx, 'Thank you for your message!')
+})
+```
+
+### saveFile(ctx, options?)
+
+Save an email attachment to disk.
+
+```typescript
+.addAction(async (ctx, { provider }) => {
+ if (ctx.attachments?.length) {
+ const filePath = await provider.saveFile(ctx, {
+ path: './downloads',
+ attachmentIndex: 0
+ })
+ console.log('Saved to:', filePath)
+ }
+})
+```
+
+### getAttachments(ctx)
+
+Get all attachments from an email.
+
+```typescript
+const attachments = provider.getAttachments(ctx)
+```
+
+### isReply(ctx)
+
+Check if the email is a reply.
+
+```typescript
+if (provider.isReply(ctx)) {
+ console.log('This is a reply to:', ctx.inReplyTo)
+}
+```
+
+### getThreadId(ctx)
+
+Get the Gmail thread ID for conversation tracking.
+
+```typescript
+const threadId = provider.getThreadId(ctx)
+```
+
+## Gmail vs Email Provider
+
+| Feature | Gmail Provider | Email Provider |
+|---------|---------------|----------------|
+| Authentication | OAuth2 | IMAP/SMTP credentials |
+| Receiving | Gmail API polling | IMAP IDLE |
+| Sending | Gmail API | SMTP |
+| Thread tracking | Native Gmail threads | Message-ID references |
+| Attachment download | Gmail API | IMAP fetch |
+| Server compatibility | Gmail only | Any IMAP/SMTP server |
+
+Use the **Gmail Provider** when you specifically need Gmail integration with OAuth2. Use the **Email Provider** when you need generic IMAP/SMTP support for any email server.
+
+## License
+
+MIT
diff --git a/packages/provider-gmail/__tests__/core.test.ts b/packages/provider-gmail/__tests__/core.test.ts
new file mode 100644
index 000000000..ffdd649e5
--- /dev/null
+++ b/packages/provider-gmail/__tests__/core.test.ts
@@ -0,0 +1,498 @@
+import { describe, expect, test, jest, beforeEach } from '@jest/globals'
+import type { gmail_v1 } from 'googleapis'
+
+// Mock @builderbot/bot before importing GmailCoreVendor
+jest.mock('@builderbot/bot', () => ({
+ ProviderClass: class MockProviderClass {},
+ utils: {
+ generateRefProvider: jest.fn((event: string) => `REF:${event}`),
+ },
+}))
+
+import { GmailCoreVendor } from '../src/gmail/core'
+import type { IGmailProviderArgs, GmailBotContext } from '../src/types'
+
+const mockConfig: IGmailProviderArgs = {
+ email: 'test@gmail.com',
+ oauth2: {
+ clientId: 'test-client-id',
+ clientSecret: 'test-client-secret',
+ refreshToken: 'test-refresh-token',
+ },
+}
+
+/**
+ * Helper to create a mock Gmail API message
+ */
+const createMockGmailMessage = (options: {
+ text?: string
+ html?: string
+ attachments?: Array<{
+ filename: string
+ mimeType: string
+ size?: number
+ attachmentId?: string
+ }>
+ from?: string
+ to?: string
+ subject?: string
+ messageId?: string
+ threadId?: string
+ inReplyTo?: string
+ references?: string
+ labelIds?: string[]
+}): gmail_v1.Schema$Message => {
+ const headers: gmail_v1.Schema$MessagePartHeader[] = [
+ { name: 'From', value: options.from || 'Test Sender ' },
+ { name: 'To', value: options.to || 'recipient@example.com' },
+ { name: 'Subject', value: options.subject !== undefined ? options.subject : 'Test Subject' },
+ { name: 'Message-ID', value: options.messageId || '' },
+ { name: 'Date', value: new Date().toISOString() },
+ ]
+
+ if (options.inReplyTo) {
+ headers.push({ name: 'In-Reply-To', value: options.inReplyTo })
+ }
+ if (options.references) {
+ headers.push({ name: 'References', value: options.references })
+ }
+
+ const parts: gmail_v1.Schema$MessagePart[] = []
+
+ if (options.text) {
+ parts.push({
+ mimeType: 'text/plain',
+ body: {
+ data: Buffer.from(options.text).toString('base64url'),
+ },
+ })
+ }
+
+ if (options.html) {
+ parts.push({
+ mimeType: 'text/html',
+ body: {
+ data: Buffer.from(options.html).toString('base64url'),
+ },
+ })
+ }
+
+ if (options.attachments) {
+ for (const att of options.attachments) {
+ parts.push({
+ filename: att.filename,
+ mimeType: att.mimeType,
+ body: {
+ attachmentId: att.attachmentId || 'att-id-123',
+ size: att.size || 100,
+ },
+ })
+ }
+ }
+
+ return {
+ id: 'gmail-msg-id',
+ threadId: options.threadId || 'thread-id-123',
+ labelIds: options.labelIds || ['INBOX'],
+ payload: {
+ headers: headers,
+ mimeType: parts.length > 1 ? 'multipart/mixed' : 'text/plain',
+ parts: parts.length > 0 ? parts : undefined,
+ body: parts.length === 0 && options.text ? {
+ data: Buffer.from(options.text || '').toString('base64url'),
+ } : undefined,
+ },
+ }
+}
+
+describe('GmailCoreVendor', () => {
+ let vendor: GmailCoreVendor
+
+ beforeEach(() => {
+ jest.clearAllMocks()
+ vendor = new GmailCoreVendor(mockConfig)
+ })
+
+ describe('parseMessageToContext - event detection', () => {
+ test('should generate _event_media_ for image attachments', () => {
+ const message = createMockGmailMessage({
+ text: 'Hello',
+ attachments: [{ filename: 'photo.png', mimeType: 'image/png' }],
+ })
+
+ const result = vendor.parseMessageToContext(message, 'gmail-id') as GmailBotContext
+
+ expect(result).not.toBeNull()
+ expect(result.body).toBe('REF:_event_media_')
+ })
+
+ test('should generate _event_media_ for video attachments', () => {
+ const message = createMockGmailMessage({
+ text: 'Check this video',
+ attachments: [{ filename: 'video.mp4', mimeType: 'video/mp4' }],
+ })
+
+ const result = vendor.parseMessageToContext(message, 'gmail-id') as GmailBotContext
+
+ expect(result).not.toBeNull()
+ expect(result.body).toBe('REF:_event_media_')
+ })
+
+ test('should generate _event_voice_note_ for audio attachments', () => {
+ const message = createMockGmailMessage({
+ text: '',
+ attachments: [{ filename: 'voice.mp3', mimeType: 'audio/mp3' }],
+ })
+
+ const result = vendor.parseMessageToContext(message, 'gmail-id') as GmailBotContext
+
+ expect(result).not.toBeNull()
+ expect(result.body).toBe('REF:_event_voice_note_')
+ })
+
+ test('should generate _event_document_ for application/pdf', () => {
+ const message = createMockGmailMessage({
+ text: '',
+ attachments: [{ filename: 'document.pdf', mimeType: 'application/pdf' }],
+ })
+
+ const result = vendor.parseMessageToContext(message, 'gmail-id') as GmailBotContext
+
+ expect(result).not.toBeNull()
+ expect(result.body).toBe('REF:_event_document_')
+ })
+
+ test('should generate _event_document_ for text/csv', () => {
+ const message = createMockGmailMessage({
+ text: '',
+ attachments: [{ filename: 'data.csv', mimeType: 'text/csv' }],
+ })
+
+ const result = vendor.parseMessageToContext(message, 'gmail-id') as GmailBotContext
+
+ expect(result).not.toBeNull()
+ expect(result.body).toBe('REF:_event_document_')
+ })
+
+ test('should NOT generate _event_document_ for text/plain attachments', () => {
+ const message = createMockGmailMessage({
+ text: '',
+ attachments: [{ filename: 'note.txt', mimeType: 'text/plain' }],
+ })
+
+ const result = vendor.parseMessageToContext(message, 'gmail-id') as GmailBotContext
+
+ expect(result).not.toBeNull()
+ expect(result.body).toBe('')
+ })
+
+ test('should NOT generate _event_document_ for text/html attachments', () => {
+ const message = createMockGmailMessage({
+ text: '',
+ attachments: [{ filename: 'page.html', mimeType: 'text/html' }],
+ })
+
+ const result = vendor.parseMessageToContext(message, 'gmail-id') as GmailBotContext
+
+ expect(result).not.toBeNull()
+ expect(result.body).toBe('')
+ })
+
+ test('should keep text body when no special attachments', () => {
+ const message = createMockGmailMessage({
+ text: 'Hello world',
+ attachments: [],
+ })
+
+ const result = vendor.parseMessageToContext(message, 'gmail-id') as GmailBotContext
+
+ expect(result).not.toBeNull()
+ expect(result.body).toBe('Hello world')
+ })
+ })
+
+ describe('parseMessageToContext - event priority', () => {
+ test('MEDIA should have priority over VOICE_NOTE', () => {
+ const message = createMockGmailMessage({
+ text: '',
+ attachments: [
+ { filename: 'photo.jpg', mimeType: 'image/jpeg' },
+ { filename: 'audio.mp3', mimeType: 'audio/mp3' },
+ ],
+ })
+
+ const result = vendor.parseMessageToContext(message, 'gmail-id') as GmailBotContext
+
+ expect(result).not.toBeNull()
+ expect(result.body).toBe('REF:_event_media_')
+ })
+
+ test('MEDIA should have priority over DOCUMENT', () => {
+ const message = createMockGmailMessage({
+ text: '',
+ attachments: [
+ { filename: 'video.mp4', mimeType: 'video/mp4' },
+ { filename: 'doc.pdf', mimeType: 'application/pdf' },
+ ],
+ })
+
+ const result = vendor.parseMessageToContext(message, 'gmail-id') as GmailBotContext
+
+ expect(result).not.toBeNull()
+ expect(result.body).toBe('REF:_event_media_')
+ })
+
+ test('VOICE_NOTE should have priority over DOCUMENT', () => {
+ const message = createMockGmailMessage({
+ text: '',
+ attachments: [
+ { filename: 'voice.ogg', mimeType: 'audio/ogg' },
+ { filename: 'doc.doc', mimeType: 'application/msword' },
+ ],
+ })
+
+ const result = vendor.parseMessageToContext(message, 'gmail-id') as GmailBotContext
+
+ expect(result).not.toBeNull()
+ expect(result.body).toBe('REF:_event_voice_note_')
+ })
+
+ test('DOCUMENT only triggers when no text body', () => {
+ const message = createMockGmailMessage({
+ text: 'Please see attached document',
+ attachments: [{ filename: 'report.pdf', mimeType: 'application/pdf' }],
+ })
+
+ const result = vendor.parseMessageToContext(message, 'gmail-id') as GmailBotContext
+
+ expect(result).not.toBeNull()
+ expect(result.body).toBe('Please see attached document')
+ })
+
+ test('MEDIA triggers even with text body', () => {
+ const message = createMockGmailMessage({
+ text: 'Check out this photo!',
+ attachments: [{ filename: 'photo.png', mimeType: 'image/png' }],
+ })
+
+ const result = vendor.parseMessageToContext(message, 'gmail-id') as GmailBotContext
+
+ expect(result).not.toBeNull()
+ expect(result.body).toBe('REF:_event_media_')
+ })
+
+ test('VOICE_NOTE triggers even with text body', () => {
+ const message = createMockGmailMessage({
+ text: 'Listen to this',
+ attachments: [{ filename: 'recording.wav', mimeType: 'audio/wav' }],
+ })
+
+ const result = vendor.parseMessageToContext(message, 'gmail-id') as GmailBotContext
+
+ expect(result).not.toBeNull()
+ expect(result.body).toBe('REF:_event_voice_note_')
+ })
+ })
+
+ describe('parseMessageToContext - email parsing', () => {
+ test('should return null for email without From header', () => {
+ const message: gmail_v1.Schema$Message = {
+ id: 'gmail-id',
+ threadId: 'thread-id',
+ payload: {
+ headers: [
+ { name: 'To', value: 'recipient@example.com' },
+ { name: 'Subject', value: 'Test' },
+ ],
+ mimeType: 'text/plain',
+ body: { data: Buffer.from('Hello').toString('base64url') },
+ },
+ }
+
+ const result = vendor.parseMessageToContext(message, 'gmail-id')
+
+ expect(result).toBeNull()
+ })
+
+ test('should detect reply from In-Reply-To header', () => {
+ const message = createMockGmailMessage({
+ text: 'Thanks for your email',
+ inReplyTo: '',
+ })
+
+ const result = vendor.parseMessageToContext(message, 'gmail-id') as GmailBotContext
+
+ expect(result).not.toBeNull()
+ expect(result.isReply).toBe(true)
+ expect(result.inReplyTo).toBe('')
+ })
+
+ test('should detect reply from References header', () => {
+ const message = createMockGmailMessage({
+ text: 'Following up',
+ references: ' ',
+ })
+
+ const result = vendor.parseMessageToContext(message, 'gmail-id') as GmailBotContext
+
+ expect(result).not.toBeNull()
+ expect(result.isReply).toBe(true)
+ })
+
+ test('should include threadId from Gmail API', () => {
+ const message = createMockGmailMessage({
+ text: 'Reply',
+ threadId: 'gmail-thread-123',
+ })
+
+ const result = vendor.parseMessageToContext(message, 'gmail-id') as GmailBotContext
+
+ expect(result).not.toBeNull()
+ expect(result.threadId).toBe('gmail-thread-123')
+ })
+
+ test('should include attachments in context', () => {
+ const message = createMockGmailMessage({
+ text: 'See attached',
+ attachments: [{ filename: 'notes.txt', mimeType: 'text/plain', size: 500 }],
+ })
+
+ const result = vendor.parseMessageToContext(message, 'gmail-id') as GmailBotContext
+
+ expect(result).not.toBeNull()
+ expect(result.attachments).toBeDefined()
+ expect(result.attachments).toHaveLength(1)
+ expect(result.attachments![0].filename).toBe('notes.txt')
+ expect(result.attachments![0].contentType).toBe('text/plain')
+ })
+
+ test('should use default subject when missing', () => {
+ const message: gmail_v1.Schema$Message = {
+ id: 'gmail-id',
+ threadId: 'thread-id',
+ payload: {
+ headers: [
+ { name: 'From', value: 'sender@example.com' },
+ { name: 'Date', value: new Date().toISOString() },
+ ],
+ mimeType: 'text/plain',
+ body: { data: Buffer.from('Hello').toString('base64url') },
+ },
+ }
+
+ const result = vendor.parseMessageToContext(message, 'gmail-id') as GmailBotContext
+
+ expect(result).not.toBeNull()
+ expect(result.subject).toBe('(no subject)')
+ })
+
+ test('should include gmailId in context', () => {
+ const message = createMockGmailMessage({ text: 'Hello' })
+
+ const result = vendor.parseMessageToContext(message, 'my-gmail-id') as GmailBotContext
+
+ expect(result).not.toBeNull()
+ expect(result.gmailId).toBe('my-gmail-id')
+ })
+ })
+
+ describe('parseMessageToContext - messageSource option', () => {
+ test('should use body by default', () => {
+ const vendorDefault = new GmailCoreVendor(mockConfig)
+ const message = createMockGmailMessage({
+ text: 'This is the body',
+ subject: 'This is the subject',
+ })
+
+ const result = vendorDefault.parseMessageToContext(message, 'gmail-id') as GmailBotContext
+
+ expect(result).not.toBeNull()
+ expect(result.body).toBe('This is the body')
+ })
+
+ test('should use subject when messageSource is "subject"', () => {
+ const vendorSubject = new GmailCoreVendor({
+ ...mockConfig,
+ messageSource: 'subject',
+ })
+ const message = createMockGmailMessage({
+ text: 'This is the body',
+ subject: 'This is the subject',
+ })
+
+ const result = vendorSubject.parseMessageToContext(message, 'gmail-id') as GmailBotContext
+
+ expect(result).not.toBeNull()
+ expect(result.body).toBe('This is the subject')
+ })
+
+ test('should use both when messageSource is "both"', () => {
+ const vendorBoth = new GmailCoreVendor({
+ ...mockConfig,
+ messageSource: 'both',
+ })
+ const message = createMockGmailMessage({
+ text: 'This is the body',
+ subject: 'This is the subject',
+ })
+
+ const result = vendorBoth.parseMessageToContext(message, 'gmail-id') as GmailBotContext
+
+ expect(result).not.toBeNull()
+ expect(result.body).toBe('This is the subject\n\nThis is the body')
+ })
+
+ test('should handle empty body with messageSource "both"', () => {
+ const vendorBoth = new GmailCoreVendor({
+ ...mockConfig,
+ messageSource: 'both',
+ })
+ const message = createMockGmailMessage({
+ text: '',
+ subject: 'Only subject',
+ })
+
+ const result = vendorBoth.parseMessageToContext(message, 'gmail-id') as GmailBotContext
+
+ expect(result).not.toBeNull()
+ expect(result.body).toBe('Only subject')
+ })
+
+ test('messageSource should not affect MEDIA event detection', () => {
+ const vendorSubject = new GmailCoreVendor({
+ ...mockConfig,
+ messageSource: 'subject',
+ })
+ const message = createMockGmailMessage({
+ text: 'Check this image',
+ subject: 'Photo for you',
+ attachments: [{ filename: 'photo.png', mimeType: 'image/png' }],
+ })
+
+ const result = vendorSubject.parseMessageToContext(message, 'gmail-id') as GmailBotContext
+
+ expect(result).not.toBeNull()
+ expect(result.body).toBe('REF:_event_media_')
+ })
+ })
+
+ describe('parseEmailHeader', () => {
+ test('should parse "Name " format', () => {
+ const result = vendor.parseEmailHeader('John Doe ')
+ expect(result.name).toBe('John Doe')
+ expect(result.address).toBe('john@example.com')
+ })
+
+ test('should parse quoted name format', () => {
+ const result = vendor.parseEmailHeader('"John Doe" ')
+ expect(result.name).toBe('John Doe')
+ expect(result.address).toBe('john@example.com')
+ })
+
+ test('should parse plain email address', () => {
+ const result = vendor.parseEmailHeader('john@example.com')
+ expect(result.name).toBe('')
+ expect(result.address).toBe('john@example.com')
+ })
+ })
+})
diff --git a/packages/provider-gmail/__tests__/gmailProvider.test.ts b/packages/provider-gmail/__tests__/gmailProvider.test.ts
new file mode 100644
index 000000000..fcdcaea17
--- /dev/null
+++ b/packages/provider-gmail/__tests__/gmailProvider.test.ts
@@ -0,0 +1,340 @@
+import { describe, expect, test, beforeEach, jest } from '@jest/globals'
+
+// Mock @builderbot/bot before importing GmailProvider
+jest.mock('@builderbot/bot', () => {
+ const EventEmitter = require('node:events')
+ return {
+ ProviderClass: class MockProviderClass extends EventEmitter {},
+ utils: {
+ generateRefProvider: jest.fn((event: string) => `REF:${event}`),
+ },
+ }
+})
+
+import { GmailProvider } from '../src/gmail/provider'
+import type { IGmailProviderArgs, GmailBotContext } from '../src/types'
+
+const mockConfig: IGmailProviderArgs = {
+ email: 'test@gmail.com',
+ oauth2: {
+ clientId: 'test-client-id',
+ clientSecret: 'test-client-secret',
+ refreshToken: 'test-refresh-token',
+ },
+}
+
+describe('GmailProvider', () => {
+ describe('constructor', () => {
+ test('should create instance with valid config', () => {
+ const provider = new GmailProvider(mockConfig)
+ expect(provider).toBeInstanceOf(GmailProvider)
+ expect(provider.globalVendorArgs.email).toBe('test@gmail.com')
+ expect(provider.globalVendorArgs.oauth2).toBeDefined()
+ })
+
+ test('should throw error without email', () => {
+ expect(() => {
+ new GmailProvider({
+ oauth2: mockConfig.oauth2,
+ } as IGmailProviderArgs)
+ }).toThrow('Gmail email address is required')
+ })
+
+ test('should throw error without OAuth2 config', () => {
+ expect(() => {
+ new GmailProvider({
+ email: 'test@gmail.com',
+ } as IGmailProviderArgs)
+ }).toThrow('OAuth2 configuration is required')
+ })
+
+ test('should throw error without OAuth2 clientId', () => {
+ expect(() => {
+ new GmailProvider({
+ email: 'test@gmail.com',
+ oauth2: {
+ clientSecret: 'secret',
+ refreshToken: 'token',
+ } as any,
+ })
+ }).toThrow('OAuth2 clientId, clientSecret, and refreshToken are required')
+ })
+
+ test('should throw error without OAuth2 clientSecret', () => {
+ expect(() => {
+ new GmailProvider({
+ email: 'test@gmail.com',
+ oauth2: {
+ clientId: 'id',
+ refreshToken: 'token',
+ } as any,
+ })
+ }).toThrow('OAuth2 clientId, clientSecret, and refreshToken are required')
+ })
+
+ test('should throw error without OAuth2 refreshToken', () => {
+ expect(() => {
+ new GmailProvider({
+ email: 'test@gmail.com',
+ oauth2: {
+ clientId: 'id',
+ clientSecret: 'secret',
+ } as any,
+ })
+ }).toThrow('OAuth2 clientId, clientSecret, and refreshToken are required')
+ })
+
+ test('should set default values', () => {
+ const provider = new GmailProvider(mockConfig)
+ expect(provider.globalVendorArgs.name).toBe('gmail-bot')
+ expect(provider.globalVendorArgs.port).toBe(3000)
+ expect(provider.globalVendorArgs.label).toBe('INBOX')
+ expect(provider.globalVendorArgs.markAsRead).toBe(true)
+ expect(provider.globalVendorArgs.pollingInterval).toBe(10000)
+ })
+
+ test('should allow overriding default values', () => {
+ const provider = new GmailProvider({
+ ...mockConfig,
+ name: 'custom-bot',
+ port: 4000,
+ label: 'Custom',
+ markAsRead: false,
+ pollingInterval: 5000,
+ })
+ expect(provider.globalVendorArgs.name).toBe('custom-bot')
+ expect(provider.globalVendorArgs.port).toBe(4000)
+ expect(provider.globalVendorArgs.label).toBe('Custom')
+ expect(provider.globalVendorArgs.markAsRead).toBe(false)
+ expect(provider.globalVendorArgs.pollingInterval).toBe(5000)
+ })
+ })
+
+ describe('helper methods', () => {
+ let provider: GmailProvider
+
+ beforeEach(() => {
+ provider = new GmailProvider(mockConfig)
+ })
+
+ test('isReply should return correct value', () => {
+ expect(provider.isReply({ isReply: true } as any)).toBe(true)
+ expect(provider.isReply({ isReply: false } as any)).toBe(false)
+ })
+
+ test('getThreadId should return threadId', () => {
+ expect(provider.getThreadId({ threadId: 'test-thread' } as any)).toBe('test-thread')
+ expect(provider.getThreadId({} as any)).toBeUndefined()
+ })
+
+ test('getAttachments should return attachments array', () => {
+ const attachments = [{ filename: 'test.txt', contentType: 'text/plain', size: 100 }]
+ expect(provider.getAttachments({ attachments } as any)).toEqual(attachments)
+ expect(provider.getAttachments({} as any)).toEqual([])
+ })
+ })
+
+ describe('thread replies', () => {
+ let provider: GmailProvider
+
+ // Helper to create a mock sendEmail function
+ const createMockSendEmail = () => {
+ const fn = jest.fn()
+ fn.mockImplementation(() => Promise.resolve({ messageId: 'reply-id', threadId: 'thread-id' }))
+ return fn
+ }
+
+ beforeEach(() => {
+ provider = new GmailProvider(mockConfig)
+ })
+
+ test('busEvents should store context in conversationContexts Map', () => {
+ const busEvents = provider['busEvents']()
+ const messageHandler = busEvents.find((e) => e.event === 'message')
+
+ expect(messageHandler).toBeDefined()
+
+ const mockPayload: GmailBotContext = {
+ from: 'user@example.com',
+ name: 'Test User',
+ body: 'Hello',
+ subject: 'Test Subject',
+ messageId: '',
+ threadId: 'thread123',
+ isReply: false,
+ gmailId: 'gmail123',
+ }
+
+ // Call the message handler
+ messageHandler!.func(mockPayload)
+
+ // Check that context was stored
+ const storedContext = (provider as any).conversationContexts.get('user@example.com')
+ expect(storedContext).toBeDefined()
+ expect(storedContext.messageId).toBe('')
+ expect(storedContext.subject).toBe('Test Subject')
+ expect(storedContext.threadId).toBe('thread123')
+ })
+
+ test('sendMessage should use stored context for inReplyTo', async () => {
+ // Setup: store a context
+ const mockContext: GmailBotContext = {
+ from: 'user@example.com',
+ name: 'Test User',
+ body: 'Hello',
+ subject: 'Original Subject',
+ messageId: '',
+ threadId: 'thread123',
+ isReply: false,
+ gmailId: 'gmail123',
+ }
+ ;(provider as any).conversationContexts.set('user@example.com', mockContext)
+
+ // Mock vendor.sendEmail
+ const mockSendEmail = createMockSendEmail()
+ ;(provider as any).vendor = { sendEmail: mockSendEmail }
+
+ // Call sendMessage
+ await provider.sendMessage('user@example.com', 'Reply message')
+
+ // Verify sendEmail was called with correct inReplyTo
+ expect(mockSendEmail).toHaveBeenCalledWith(
+ 'user@example.com',
+ 'Re: Original Subject',
+ 'Reply message',
+ expect.objectContaining({
+ inReplyTo: '',
+ threadId: 'thread123',
+ })
+ )
+ })
+
+ test('sendMessage should add Re: prefix to subject', async () => {
+ const mockContext: GmailBotContext = {
+ from: 'user@example.com',
+ name: 'Test User',
+ body: 'Hello',
+ subject: 'Question about product',
+ messageId: '',
+ threadId: 'thread123',
+ isReply: false,
+ gmailId: 'gmail123',
+ }
+ ;(provider as any).conversationContexts.set('user@example.com', mockContext)
+
+ const mockSendEmail = createMockSendEmail()
+ ;(provider as any).vendor = { sendEmail: mockSendEmail }
+
+ await provider.sendMessage('user@example.com', 'Here is the answer')
+
+ expect(mockSendEmail).toHaveBeenCalledWith(
+ 'user@example.com',
+ 'Re: Question about product',
+ 'Here is the answer',
+ expect.any(Object)
+ )
+ })
+
+ test('sendMessage should include references and threadId', async () => {
+ const mockContext: GmailBotContext = {
+ from: 'user@example.com',
+ name: 'Test User',
+ body: 'Hello',
+ subject: 'Thread test',
+ messageId: '',
+ threadId: 'thread-start-id',
+ isReply: false,
+ gmailId: 'gmail123',
+ }
+ ;(provider as any).conversationContexts.set('user@example.com', mockContext)
+
+ const mockSendEmail = createMockSendEmail()
+ ;(provider as any).vendor = { sendEmail: mockSendEmail }
+
+ await provider.sendMessage('user@example.com', 'Following up')
+
+ expect(mockSendEmail).toHaveBeenCalledWith(
+ 'user@example.com',
+ expect.any(String),
+ 'Following up',
+ expect.objectContaining({
+ references: ['thread-start-id'],
+ threadId: 'thread-start-id',
+ })
+ )
+ })
+
+ test('sendMessage should not add Re: if already present', async () => {
+ const mockContext: GmailBotContext = {
+ from: 'user@example.com',
+ name: 'Test User',
+ body: 'Hello',
+ subject: 'Re: Already a reply',
+ messageId: '',
+ threadId: 'thread123',
+ isReply: true,
+ gmailId: 'gmail123',
+ }
+ ;(provider as any).conversationContexts.set('user@example.com', mockContext)
+
+ const mockSendEmail = createMockSendEmail()
+ ;(provider as any).vendor = { sendEmail: mockSendEmail }
+
+ await provider.sendMessage('user@example.com', 'Continuing the thread')
+
+ // Should NOT have "Re: Re:"
+ expect(mockSendEmail).toHaveBeenCalledWith(
+ 'user@example.com',
+ 'Re: Already a reply', // Not "Re: Re: Already a reply"
+ 'Continuing the thread',
+ expect.any(Object)
+ )
+ })
+
+ test('sendMessage without context should use default subject', async () => {
+ // No context stored for this user
+ const mockSendEmail = createMockSendEmail()
+ ;(provider as any).vendor = { sendEmail: mockSendEmail }
+
+ await provider.sendMessage('newuser@example.com', 'Hello!')
+
+ expect(mockSendEmail).toHaveBeenCalledWith(
+ 'newuser@example.com',
+ 'Message from Bot',
+ 'Hello!',
+ expect.objectContaining({
+ inReplyTo: undefined,
+ })
+ )
+ })
+
+ test('sendMessage should allow custom subject override', async () => {
+ const mockContext: GmailBotContext = {
+ from: 'user@example.com',
+ name: 'Test User',
+ body: 'Hello',
+ subject: 'Original',
+ messageId: '',
+ threadId: 'thread123',
+ isReply: false,
+ gmailId: 'gmail123',
+ }
+ ;(provider as any).conversationContexts.set('user@example.com', mockContext)
+
+ const mockSendEmail = createMockSendEmail()
+ ;(provider as any).vendor = { sendEmail: mockSendEmail }
+
+ await provider.sendMessage('user@example.com', 'Custom message', {
+ subject: 'Custom Subject',
+ })
+
+ // Custom subject should be used with Re: prefix since there's context
+ expect(mockSendEmail).toHaveBeenCalledWith(
+ 'user@example.com',
+ 'Re: Custom Subject',
+ 'Custom message',
+ expect.any(Object)
+ )
+ })
+ })
+})
diff --git a/packages/provider-gmail/__tests__/utils.test.ts b/packages/provider-gmail/__tests__/utils.test.ts
new file mode 100644
index 000000000..0dbac4512
--- /dev/null
+++ b/packages/provider-gmail/__tests__/utils.test.ts
@@ -0,0 +1,250 @@
+import { describe, expect, test } from '@jest/globals'
+
+import {
+ extractEmailAddress,
+ extractEmailName,
+ isValidEmail,
+ cleanEmail,
+ parseEmailList,
+ formatEmailAddress,
+ htmlToText,
+ isHtml,
+ extractThreadId,
+ isReplySubject,
+ stripReplyPrefix,
+ addReplyPrefix,
+ parseMimeType,
+ mimeToExtension,
+} from '../src/utils'
+
+describe('#extractEmailAddress', () => {
+ test('should extract email from "Name " format', () => {
+ const input = 'John Doe '
+ const result = extractEmailAddress(input)
+ expect(result).toBe('john@example.com')
+ })
+
+ test('should handle plain email address', () => {
+ const input = 'john@example.com'
+ const result = extractEmailAddress(input)
+ expect(result).toBe('john@example.com')
+ })
+
+ test('should handle email with quotes in name', () => {
+ const input = '"John Doe" '
+ const result = extractEmailAddress(input)
+ expect(result).toBe('john@example.com')
+ })
+
+ test('should return empty string for empty input', () => {
+ const result = extractEmailAddress('')
+ expect(result).toBe('')
+ })
+})
+
+describe('#extractEmailName', () => {
+ test('should extract name from "Name " format', () => {
+ const input = 'John Doe '
+ const result = extractEmailName(input)
+ expect(result).toBe('John Doe')
+ })
+
+ test('should handle quoted name', () => {
+ const input = '"John Doe" '
+ const result = extractEmailName(input)
+ expect(result).toBe('John Doe')
+ })
+
+ test('should return empty string for plain email', () => {
+ const input = 'john@example.com'
+ const result = extractEmailName(input)
+ expect(result).toBe('')
+ })
+})
+
+describe('#isValidEmail', () => {
+ test('should return true for valid email', () => {
+ expect(isValidEmail('john@example.com')).toBe(true)
+ expect(isValidEmail('test.user@domain.org')).toBe(true)
+ })
+
+ test('should return false for invalid email', () => {
+ expect(isValidEmail('invalid')).toBe(false)
+ expect(isValidEmail('invalid@')).toBe(false)
+ expect(isValidEmail('@domain.com')).toBe(false)
+ expect(isValidEmail('')).toBe(false)
+ })
+})
+
+describe('#cleanEmail', () => {
+ test('should clean and normalize email', () => {
+ const result = cleanEmail(' John@EXAMPLE.COM ')
+ expect(result).toBe('john@example.com')
+ })
+
+ test('should extract and clean email from full format', () => {
+ const result = cleanEmail('John Doe ')
+ expect(result).toBe('john@example.com')
+ })
+})
+
+describe('#parseEmailList', () => {
+ test('should parse comma-separated emails', () => {
+ const result = parseEmailList('john@example.com, jane@example.com')
+ expect(result).toEqual(['john@example.com', 'jane@example.com'])
+ })
+
+ test('should parse semicolon-separated emails', () => {
+ const result = parseEmailList('john@example.com; jane@example.com')
+ expect(result).toEqual(['john@example.com', 'jane@example.com'])
+ })
+
+ test('should filter out invalid emails', () => {
+ const result = parseEmailList('john@example.com, invalid, jane@example.com')
+ expect(result).toEqual(['john@example.com', 'jane@example.com'])
+ })
+})
+
+describe('#formatEmailAddress', () => {
+ test('should format with name', () => {
+ const result = formatEmailAddress('john@example.com', 'John Doe')
+ expect(result).toBe('"John Doe" ')
+ })
+
+ test('should return plain email without name', () => {
+ const result = formatEmailAddress('john@example.com')
+ expect(result).toBe('john@example.com')
+ })
+})
+
+describe('#htmlToText', () => {
+ test('should strip HTML tags', () => {
+ const html = 'Hello World
'
+ const result = htmlToText(html)
+ expect(result).toContain('Hello')
+ expect(result).toContain('World')
+ expect(result).not.toContain('<')
+ })
+
+ test('should decode HTML entities', () => {
+ const html = '& < > "'
+ const result = htmlToText(html)
+ expect(result).toBe('& < > "')
+ })
+
+ test('should handle empty input', () => {
+ expect(htmlToText('')).toBe('')
+ })
+})
+
+describe('#isHtml', () => {
+ test('should detect HTML content', () => {
+ expect(isHtml('Hello
')).toBe(true)
+ expect(isHtml('Content
')).toBe(true)
+ })
+
+ test('should return false for plain text', () => {
+ expect(isHtml('Hello World')).toBe(false)
+ expect(isHtml('')).toBe(false)
+ })
+})
+
+describe('#extractThreadId', () => {
+ test('should extract from references array', () => {
+ const references = ['', '']
+ const result = extractThreadId(references)
+ expect(result).toBe('')
+ })
+
+ test('should extract from references string', () => {
+ const references = ' '
+ const result = extractThreadId(references)
+ expect(result).toBe('')
+ })
+
+ test('should fall back to inReplyTo', () => {
+ const result = extractThreadId(undefined, '')
+ expect(result).toBe('')
+ })
+
+ test('should return undefined when no data', () => {
+ const result = extractThreadId(undefined, undefined)
+ expect(result).toBeUndefined()
+ })
+})
+
+describe('#isReplySubject', () => {
+ test('should detect reply subjects', () => {
+ expect(isReplySubject('Re: Hello')).toBe(true)
+ expect(isReplySubject('RE: Hello')).toBe(true)
+ expect(isReplySubject('re: Hello')).toBe(true)
+ expect(isReplySubject('Aw: Hello')).toBe(true) // German
+ expect(isReplySubject('Sv: Hello')).toBe(true) // Swedish
+ })
+
+ test('should return false for non-reply subjects', () => {
+ expect(isReplySubject('Hello')).toBe(false)
+ expect(isReplySubject('Meeting Request')).toBe(false)
+ expect(isReplySubject('')).toBe(false)
+ })
+})
+
+describe('#stripReplyPrefix', () => {
+ test('should strip reply prefix', () => {
+ expect(stripReplyPrefix('Re: Hello')).toBe('Hello')
+ expect(stripReplyPrefix('RE: Hello')).toBe('Hello')
+ })
+
+ test('should not modify non-reply subjects', () => {
+ expect(stripReplyPrefix('Hello')).toBe('Hello')
+ })
+})
+
+describe('#addReplyPrefix', () => {
+ test('should add reply prefix', () => {
+ expect(addReplyPrefix('Hello')).toBe('Re: Hello')
+ })
+
+ test('should not add prefix if already present', () => {
+ expect(addReplyPrefix('Re: Hello')).toBe('Re: Hello')
+ })
+
+ test('should handle empty subject', () => {
+ expect(addReplyPrefix('')).toBe('Re:')
+ })
+})
+
+describe('#parseMimeType', () => {
+ test('should parse simple MIME type', () => {
+ const result = parseMimeType('text/plain')
+ expect(result.type).toBe('text')
+ expect(result.subtype).toBe('plain')
+ })
+
+ test('should parse MIME type with parameters', () => {
+ const result = parseMimeType('text/plain; charset=utf-8')
+ expect(result.type).toBe('text')
+ expect(result.subtype).toBe('plain')
+ expect(result.parameters.charset).toBe('utf-8')
+ })
+
+ test('should handle empty input', () => {
+ const result = parseMimeType('')
+ expect(result.type).toBe('text')
+ expect(result.subtype).toBe('plain')
+ })
+})
+
+describe('#mimeToExtension', () => {
+ test('should return correct extension for known types', () => {
+ expect(mimeToExtension('text/plain')).toBe('txt')
+ expect(mimeToExtension('text/html')).toBe('html')
+ expect(mimeToExtension('application/pdf')).toBe('pdf')
+ expect(mimeToExtension('image/jpeg')).toBe('jpg')
+ expect(mimeToExtension('image/png')).toBe('png')
+ })
+
+ test('should return subtype for unknown types', () => {
+ expect(mimeToExtension('application/unknown')).toBe('unknown')
+ })
+})
diff --git a/packages/provider-gmail/jest.config.ts b/packages/provider-gmail/jest.config.ts
new file mode 100644
index 000000000..ca2c6a76f
--- /dev/null
+++ b/packages/provider-gmail/jest.config.ts
@@ -0,0 +1,14 @@
+/**
+ * For a detailed explanation regarding each configuration property, visit:
+ * https://jestjs.io/docs/configuration
+ */
+
+import type { Config } from 'jest'
+
+const config: Config = {
+ preset: 'ts-jest',
+ verbose: true,
+ cache: true,
+}
+
+export default config
diff --git a/packages/provider-gmail/package.json b/packages/provider-gmail/package.json
new file mode 100644
index 000000000..46b324824
--- /dev/null
+++ b/packages/provider-gmail/package.json
@@ -0,0 +1,48 @@
+{
+ "name": "@builderbot/provider-gmail",
+ "version": "1.3.15-alpha.10",
+ "description": "Gmail provider for BuilderBot using Gmail API with OAuth2",
+ "author": "Leifer Mendez ",
+ "homepage": "https://github.com/codigoencasa/bot-whatsapp#readme",
+ "license": "MIT",
+ "main": "dist/index.cjs",
+ "types": "dist/index.d.ts",
+ "type": "module",
+ "files": [
+ "./dist/"
+ ],
+ "repository": {
+ "type": "git",
+ "url": "git+https://github.com/codigoencasa/bot-whatsapp.git"
+ },
+ "scripts": {
+ "build": "rimraf dist && rollup --config",
+ "test": "jest",
+ "test:coverage": "jest --coverage",
+ "test:watch": "jest --watchAll --coverage"
+ },
+ "directories": {
+ "lib": "dist",
+ "test": "__tests__"
+ },
+ "bugs": {
+ "url": "https://github.com/codigoencasa/bot-whatsapp/issues"
+ },
+ "devDependencies": {
+ "@builderbot/bot": "^1.3.15-alpha.10",
+ "@jest/globals": "^30.2.0",
+ "@rollup/plugin-commonjs": "^29.0.0",
+ "@rollup/plugin-json": "^6.1.0",
+ "@rollup/plugin-node-resolve": "^16.0.3",
+ "@types/node": "^24.10.2",
+ "@types/polka": "^0.5.7",
+ "cors": "^2.8.5",
+ "rimraf": "^6.1.2",
+ "rollup-plugin-typescript2": "^0.36.0"
+ },
+ "dependencies": {
+ "body-parser": "^2.2.1",
+ "googleapis": "^144.0.0",
+ "polka": "^0.5.2"
+ }
+}
diff --git a/packages/provider-gmail/rollup.config.js b/packages/provider-gmail/rollup.config.js
new file mode 100644
index 000000000..45aaf4802
--- /dev/null
+++ b/packages/provider-gmail/rollup.config.js
@@ -0,0 +1,24 @@
+import typescript from 'rollup-plugin-typescript2'
+import json from '@rollup/plugin-json'
+import commonjs from '@rollup/plugin-commonjs'
+import { nodeResolve } from '@rollup/plugin-node-resolve'
+
+export default {
+ input: ['src/index.ts'],
+ output: [
+ {
+ dir: 'dist',
+ entryFileNames: '[name].cjs',
+ format: 'cjs',
+ exports: 'named',
+ },
+ ],
+ plugins: [
+ json(),
+ nodeResolve({
+ resolveOnly: (module) => !/googleapis|@builderbot\/bot/i.test(module),
+ }),
+ commonjs(),
+ typescript(),
+ ],
+}
diff --git a/packages/provider-gmail/src/gmail/core.ts b/packages/provider-gmail/src/gmail/core.ts
new file mode 100644
index 000000000..527d86e66
--- /dev/null
+++ b/packages/provider-gmail/src/gmail/core.ts
@@ -0,0 +1,668 @@
+import { utils } from '@builderbot/bot'
+import { google, type gmail_v1 } from 'googleapis'
+import EventEmitter from 'node:events'
+
+import type { IGmailProviderArgs, GmailBotContext, GmailSendOptions, GmailAttachment } from '../types'
+
+/**
+ * Class representing GmailCoreVendor, handles Gmail API operations.
+ * Uses OAuth2 for authentication and Gmail API for sending/receiving.
+ * @extends EventEmitter
+ */
+export class GmailCoreVendor extends EventEmitter {
+ private gmail: gmail_v1.Gmail | null = null
+ private config: IGmailProviderArgs
+ private isConnected: boolean = false
+ private pollingTimer: ReturnType | null = null
+ private lastHistoryId: string | null = null
+ private processedMessageIds: Set = new Set()
+
+ constructor(config: IGmailProviderArgs) {
+ super()
+ this.config = config
+ }
+
+ /**
+ * Initialize OAuth2 client and connect to Gmail API
+ */
+ public async connect(): Promise {
+ try {
+ const oauth2Client = new google.auth.OAuth2(
+ this.config.oauth2.clientId,
+ this.config.oauth2.clientSecret
+ )
+
+ oauth2Client.setCredentials({
+ refresh_token: this.config.oauth2.refreshToken,
+ })
+
+ // Force token refresh to validate credentials
+ await oauth2Client.getAccessToken()
+
+ this.gmail = google.gmail({ version: 'v1', auth: oauth2Client })
+ this.isConnected = true
+
+ console.log('[GmailProvider] Connected to Gmail API')
+
+ // Get initial historyId to track changes
+ const profile = await this.gmail.users.getProfile({ userId: 'me' })
+ this.lastHistoryId = profile.data.historyId || null
+
+ const host = {
+ email: this.config.email,
+ phone: this.config.email,
+ }
+ this.emit('host', host)
+ this.emit('ready')
+
+ // Start polling for new emails
+ this.startPolling()
+ } catch (error) {
+ console.error('[GmailProvider] Failed to connect to Gmail API:', error)
+ this.emit('auth_failure', error)
+ throw error
+ }
+ }
+
+ /**
+ * Start polling for new emails using Gmail history API
+ */
+ private startPolling(): void {
+ if (!this.gmail || !this.isConnected) return
+
+ const interval = this.config.pollingInterval || 10000
+
+ console.log(`[GmailProvider] Starting polling every ${interval}ms`)
+
+ this.pollingTimer = setInterval(async () => {
+ try {
+ await this.checkForNewEmails()
+ } catch (error) {
+ console.error('[GmailProvider] Polling error:', error)
+ this.emit('error', error)
+ }
+ }, interval)
+ }
+
+ /**
+ * Check for new emails using Gmail history or messages.list
+ */
+ private async checkForNewEmails(): Promise {
+ if (!this.gmail || !this.isConnected) return
+
+ try {
+ if (this.lastHistoryId) {
+ await this.checkHistory()
+ } else {
+ await this.checkLatestMessages()
+ }
+ } catch (error: any) {
+ // If history is invalid (404), fall back to listing messages
+ if (error?.code === 404 || error?.status === 404) {
+ console.log('[GmailProvider] History expired, falling back to messages.list')
+ this.lastHistoryId = null
+ await this.checkLatestMessages()
+ } else {
+ throw error
+ }
+ }
+ }
+
+ /**
+ * Check for new messages using Gmail history API
+ */
+ private async checkHistory(): Promise {
+ if (!this.gmail || !this.lastHistoryId) return
+
+ const label = this.config.label || 'INBOX'
+
+ const response = await this.gmail.users.history.list({
+ userId: 'me',
+ startHistoryId: this.lastHistoryId,
+ labelId: label,
+ historyTypes: ['messageAdded'],
+ })
+
+ if (response.data.historyId) {
+ this.lastHistoryId = response.data.historyId
+ }
+
+ const history = response.data.history || []
+
+ for (const record of history) {
+ const messagesAdded = record.messagesAdded || []
+ for (const added of messagesAdded) {
+ const messageId = added.message?.id
+ if (messageId && !this.processedMessageIds.has(messageId)) {
+ this.processedMessageIds.add(messageId)
+ await this.processMessage(messageId)
+ }
+ }
+ }
+
+ // Limit the size of processedMessageIds to prevent memory leaks
+ if (this.processedMessageIds.size > 1000) {
+ const entries = Array.from(this.processedMessageIds)
+ this.processedMessageIds = new Set(entries.slice(-500))
+ }
+ }
+
+ /**
+ * Check latest messages when history is not available
+ */
+ private async checkLatestMessages(): Promise {
+ if (!this.gmail) return
+
+ const label = this.config.label || 'INBOX'
+
+ const response = await this.gmail.users.messages.list({
+ userId: 'me',
+ labelIds: [label],
+ q: 'is:unread',
+ maxResults: 10,
+ })
+
+ const messages = response.data.messages || []
+
+ for (const msg of messages) {
+ if (msg.id && !this.processedMessageIds.has(msg.id)) {
+ this.processedMessageIds.add(msg.id)
+ await this.processMessage(msg.id)
+ }
+ }
+
+ // Update historyId from profile for future checks
+ const profile = await this.gmail.users.getProfile({ userId: 'me' })
+ if (profile.data.historyId) {
+ this.lastHistoryId = profile.data.historyId
+ }
+
+ // Limit the size of processedMessageIds
+ if (this.processedMessageIds.size > 1000) {
+ const entries = Array.from(this.processedMessageIds)
+ this.processedMessageIds = new Set(entries.slice(-500))
+ }
+ }
+
+ /**
+ * Process a single Gmail message by ID
+ */
+ private async processMessage(gmailId: string): Promise {
+ if (!this.gmail) return
+
+ try {
+ const response = await this.gmail.users.messages.get({
+ userId: 'me',
+ id: gmailId,
+ format: 'full',
+ })
+
+ const message = response.data
+ const context = this.parseMessageToContext(message, gmailId)
+
+ if (context) {
+ // Mark as read if configured
+ if (this.config.markAsRead !== false) {
+ try {
+ await this.gmail.users.messages.modify({
+ userId: 'me',
+ id: gmailId,
+ requestBody: {
+ removeLabelIds: ['UNREAD'],
+ },
+ })
+ } catch (markError) {
+ console.error('[GmailProvider] Failed to mark email as read:', markError)
+ }
+ }
+
+ console.log('[GmailProvider] About to emit message event')
+ this.emit('message', context)
+ console.log('[GmailProvider] Message event emitted')
+ }
+ } catch (error) {
+ console.error('[GmailProvider] Failed to process message:', error)
+ }
+ }
+
+ /**
+ * Parse a Gmail API message object to GmailBotContext
+ */
+ parseMessageToContext(message: gmail_v1.Schema$Message, gmailId: string): GmailBotContext | null {
+ const headers = message.payload?.headers || []
+
+ const getHeader = (name: string): string | undefined => {
+ const header = headers.find((h) => h.name?.toLowerCase() === name.toLowerCase())
+ return header?.value || undefined
+ }
+
+ const fromRaw = getHeader('From')
+ if (!fromRaw) {
+ console.warn('[GmailProvider] Email has no From header, skipping')
+ return null
+ }
+
+ const fromParsed = this.parseEmailHeader(fromRaw)
+
+ // Extract recipients
+ const toRaw = getHeader('To')
+ const ccRaw = getHeader('Cc')
+ const toAddresses = toRaw ? this.parseEmailListHeader(toRaw) : []
+ const ccAddresses = ccRaw ? this.parseEmailListHeader(ccRaw) : undefined
+
+ // Extract body
+ const { text, html } = this.extractBody(message.payload)
+
+ // Extract attachments info
+ const attachments = this.extractAttachmentInfo(message.payload)
+
+ // Check if reply
+ const inReplyTo = getHeader('In-Reply-To')
+ const referencesRaw = getHeader('References')
+ const references = referencesRaw ? referencesRaw.split(/\s+/).filter(Boolean) : undefined
+ const isReply = !!(inReplyTo || (references && references.length > 0))
+
+ const subject = getHeader('Subject') || '(no subject)'
+ const messageId = getHeader('Message-ID') || `${gmailId}@gmail.com`
+ const dateStr = getHeader('Date')
+ const date = dateStr ? new Date(dateStr) : new Date()
+
+ // Determine attachment types for event routing
+ const hasMedia = attachments.some(
+ (a) => a.contentType.startsWith('image/') || a.contentType.startsWith('video/')
+ )
+ const hasAudio = attachments.some((a) => a.contentType.startsWith('audio/'))
+ const hasDocument = attachments.some((a) => {
+ if (a.contentType.startsWith('application/')) return true
+ if (
+ a.contentType.startsWith('text/') &&
+ !a.contentType.includes('plain') &&
+ !a.contentType.includes('html')
+ ) {
+ return true
+ }
+ return false
+ })
+
+ // Determine base body based on messageSource config
+ let body = ''
+ const messageSource = this.config.messageSource || 'body'
+ switch (messageSource) {
+ case 'subject':
+ body = subject
+ break
+ case 'both':
+ body = [getHeader('Subject'), text].filter(Boolean).join('\n\n')
+ break
+ case 'body':
+ default:
+ body = text || ''
+ break
+ }
+
+ // Build body - generate special events for attachments
+ if (hasMedia) {
+ body = utils.generateRefProvider('_event_media_')
+ } else if (hasAudio) {
+ body = utils.generateRefProvider('_event_voice_note_')
+ } else if (hasDocument && !body.trim()) {
+ body = utils.generateRefProvider('_event_document_')
+ }
+
+ const context: GmailBotContext = {
+ from: fromParsed.address,
+ name: fromParsed.name || fromParsed.address,
+ body: body,
+ subject: subject,
+ messageId: messageId,
+ threadId: message.threadId || undefined,
+ inReplyTo: inReplyTo,
+ attachments: attachments.length > 0 ? attachments : undefined,
+ isReply: isReply,
+ html: html || undefined,
+ to: toAddresses,
+ cc: ccAddresses,
+ date: date,
+ gmailId: gmailId,
+ }
+
+ return context
+ }
+
+ /**
+ * Extract body (text and html) from message payload
+ */
+ private extractBody(payload: gmail_v1.Schema$MessagePart | undefined): { text: string; html: string } {
+ let text = ''
+ let html = ''
+
+ if (!payload) return { text, html }
+
+ const extractFromPart = (part: gmail_v1.Schema$MessagePart): void => {
+ if (part.mimeType === 'text/plain' && part.body?.data) {
+ text = Buffer.from(part.body.data, 'base64url').toString('utf-8')
+ } else if (part.mimeType === 'text/html' && part.body?.data) {
+ html = Buffer.from(part.body.data, 'base64url').toString('utf-8')
+ }
+
+ if (part.parts) {
+ for (const subPart of part.parts) {
+ extractFromPart(subPart)
+ }
+ }
+ }
+
+ extractFromPart(payload)
+
+ // If no text but has HTML, do basic conversion
+ if (!text && html) {
+ text = html
+ .replace(/