feat: add gmail integration for searching and reply to emails (#34)

* Add gmail integration for google verification

* Fix npm run build
This commit is contained in:
Abhishek 2025-10-21 12:11:34 +05:30 committed by GitHub
parent b9d1720d94
commit 6503d806c5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 407 additions and 5 deletions

View file

@ -1,6 +1,7 @@
import base64
import os
from loguru import logger
from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter
from api.constants import ENABLE_TRACING
@ -23,7 +24,7 @@ def setup_pipeline_tracing():
langfuse_secret_key = os.environ.get("LANGFUSE_SECRET_KEY")
if not all([langfuse_host, langfuse_public_key, langfuse_secret_key]):
print(
logger.warning(
"Warning: ENABLE_TRACING is true but Langfuse credentials are not configured. Tracing disabled."
)
return
@ -39,6 +40,3 @@ def setup_pipeline_tracing():
otlp_exporter = OTLPSpanExporter()
setup_tracing(service_name="dograh-pipeline", exporter=otlp_exporter)
print("Langfuse tracing enabled")
else:
print("Tracing disabled (ENABLE_TRACING=false)")

View file

@ -15,7 +15,7 @@ BASE_LOG_DIR="$BASE_DIR/logs" # Base logs directory
TIMESTAMP=$(date +"%Y%m%d_%H%M%S")
LOG_DIR="$BASE_LOG_DIR/$TIMESTAMP" # Timestamped log directory
LATEST_LINK="$BASE_LOG_DIR/latest" # Symlink to latest logs
VENV_PATH="$BASE_DIR/venv"
VENV_PATH="$(dirname "$BASE_DIR")/venv"
ARQ_WORKERS=${ARQ_WORKERS:-1}

View file

@ -0,0 +1,390 @@
'use client';
import { useParams, useRouter } from 'next/navigation';
import { useCallback, useEffect, useState } from 'react';
import { getIntegrationAccessTokenApiV1IntegrationIntegrationIdAccessTokenGet } from '@/client/sdk.gen';
import { useAuth } from '@/lib/auth';
import logger from '@/lib/logger';
interface Email {
id: string;
threadId: string;
subject: string;
from: string;
snippet: string;
date: string;
}
interface EmailDetail {
id: string;
threadId: string;
subject: string;
from: string;
to: string;
date: string;
body: string;
}
interface GmailHeader {
name: string;
value: string;
}
interface GmailPayloadPart {
mimeType: string;
body: {
data?: string;
};
}
export default function GmailSearchPage() {
const params = useParams();
const router = useRouter();
const integrationId = parseInt(params.id as string);
const { getAccessToken, redirectToLogin } = useAuth();
const [accessToken, setAccessToken] = useState<string | null>(null);
const [searchQuery, setSearchQuery] = useState('');
const [emails, setEmails] = useState<Email[]>([]);
const [selectedEmail, setSelectedEmail] = useState<EmailDetail | null>(null);
const [replyText, setReplyText] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [sendingReply, setSendingReply] = useState(false);
const fetchAccessToken = useCallback(async () => {
try {
const token = await getAccessToken();
if (!token) {
redirectToLogin();
return;
}
const response = await getIntegrationAccessTokenApiV1IntegrationIntegrationIdAccessTokenGet({
path: { integration_id: integrationId },
headers: { Authorization: `Bearer ${token}` },
});
if (response.data?.access_token) {
setAccessToken(response.data.access_token);
} else {
setError('Failed to get access token');
}
} catch (err) {
logger.error('Error fetching access token:', err);
setError('Failed to fetch access token. Please try again.');
}
}, [getAccessToken, redirectToLogin, integrationId]);
useEffect(() => {
fetchAccessToken();
}, [fetchAccessToken]);
const searchEmails = async () => {
if (!accessToken || !searchQuery.trim()) return;
setLoading(true);
setError(null);
setEmails([]);
setSelectedEmail(null);
try {
const response = await fetch(
`https://gmail.googleapis.com/gmail/v1/users/me/messages?q=${encodeURIComponent(searchQuery)}&maxResults=20`,
{
headers: {
Authorization: `Bearer ${accessToken}`,
},
}
);
if (!response.ok) {
throw new Error(`Gmail API error: ${response.statusText}`);
}
const data = await response.json();
if (!data.messages || data.messages.length === 0) {
setEmails([]);
return;
}
// Fetch details for each message
const emailPromises = data.messages.map(async (msg: { id: string }) => {
const msgResponse = await fetch(
`https://gmail.googleapis.com/gmail/v1/users/me/messages/${msg.id}?format=metadata&metadataHeaders=Subject&metadataHeaders=From&metadataHeaders=Date`,
{
headers: {
Authorization: `Bearer ${accessToken}`,
},
}
);
return msgResponse.json();
});
const emailDetails = await Promise.all(emailPromises);
const formattedEmails: Email[] = emailDetails.map((email) => {
const headers = email.payload.headers as GmailHeader[];
const subject = headers.find((h) => h.name === 'Subject')?.value || 'No Subject';
const from = headers.find((h) => h.name === 'From')?.value || 'Unknown';
const date = headers.find((h) => h.name === 'Date')?.value || '';
return {
id: email.id,
threadId: email.threadId,
subject,
from,
snippet: email.snippet || '',
date,
};
});
setEmails(formattedEmails);
} catch (err) {
logger.error('Error searching emails:', err);
setError('Failed to search emails. Please try again.');
} finally {
setLoading(false);
}
};
const loadEmailDetail = async (emailId: string) => {
if (!accessToken) return;
setLoading(true);
setError(null);
try {
const response = await fetch(
`https://gmail.googleapis.com/gmail/v1/users/me/messages/${emailId}?format=full`,
{
headers: {
Authorization: `Bearer ${accessToken}`,
},
}
);
if (!response.ok) {
throw new Error(`Gmail API error: ${response.statusText}`);
}
const email = await response.json();
const headers = email.payload.headers as GmailHeader[];
const subject = headers.find((h) => h.name === 'Subject')?.value || 'No Subject';
const from = headers.find((h) => h.name === 'From')?.value || 'Unknown';
const to = headers.find((h) => h.name === 'To')?.value || 'Unknown';
const date = headers.find((h) => h.name === 'Date')?.value || '';
// Extract email body
let body = '';
if (email.payload.body.data) {
body = atob(email.payload.body.data.replace(/-/g, '+').replace(/_/g, '/'));
} else if (email.payload.parts) {
const parts = email.payload.parts as GmailPayloadPart[];
const textPart = parts.find((part) => part.mimeType === 'text/plain');
if (textPart && textPart.body.data) {
body = atob(textPart.body.data.replace(/-/g, '+').replace(/_/g, '/'));
}
}
setSelectedEmail({
id: email.id,
threadId: email.threadId,
subject,
from,
to,
date,
body,
});
} catch (err) {
logger.error('Error loading email detail:', err);
setError('Failed to load email details. Please try again.');
} finally {
setLoading(false);
}
};
const sendReply = async () => {
if (!accessToken || !selectedEmail || !replyText.trim()) return;
setSendingReply(true);
setError(null);
try {
// Create the email message
const to = selectedEmail.from.match(/<(.+)>/)?.[1] || selectedEmail.from;
const subject = selectedEmail.subject.startsWith('Re:')
? selectedEmail.subject
: `Re: ${selectedEmail.subject}`;
const messageParts = [
`To: ${to}`,
`Subject: ${subject}`,
`In-Reply-To: ${selectedEmail.id}`,
`References: ${selectedEmail.id}`,
'',
replyText,
];
const message = messageParts.join('\n');
const encodedMessage = btoa(message)
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=+$/, '');
const response = await fetch(
`https://gmail.googleapis.com/gmail/v1/users/me/messages/send`,
{
method: 'POST',
headers: {
Authorization: `Bearer ${accessToken}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
raw: encodedMessage,
threadId: selectedEmail.threadId,
}),
}
);
if (!response.ok) {
throw new Error(`Gmail API error: ${response.statusText}`);
}
alert('Reply sent successfully!');
setReplyText('');
} catch (err) {
logger.error('Error sending reply:', err);
setError('Failed to send reply. Please try again.');
} finally {
setSendingReply(false);
}
};
return (
<div className="container mx-auto px-4 py-8">
<div className="mb-6">
<button
onClick={() => router.back()}
className="text-blue-600 hover:text-blue-800 mb-4"
>
Back to Integrations
</button>
<h1 className="text-2xl font-bold">Gmail Search</h1>
</div>
{/* Search Section */}
<div className="mb-6">
<div className="flex gap-2">
<input
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
onKeyPress={(e) => e.key === 'Enter' && searchEmails()}
placeholder="Search emails (e.g., from:user@example.com, subject:meeting)"
className="flex-1 px-4 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
disabled={!accessToken || loading}
/>
<button
onClick={searchEmails}
disabled={!accessToken || loading || !searchQuery.trim()}
className="px-6 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:bg-gray-400 disabled:cursor-not-allowed"
>
{loading ? 'Searching...' : 'Search'}
</button>
</div>
</div>
{error && (
<div className="mb-6 p-4 bg-red-50 border border-red-200 text-red-700 rounded-md">
{error}
</div>
)}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Email List */}
<div className="bg-white border border-gray-200 rounded-lg">
<div className="p-4 border-b border-gray-200">
<h2 className="text-lg font-semibold">Search Results</h2>
</div>
<div className="divide-y divide-gray-200 max-h-[600px] overflow-y-auto">
{emails.length === 0 && !loading && (
<div className="p-8 text-center text-gray-500">
{searchQuery ? 'No emails found' : 'Enter a search query to find emails'}
</div>
)}
{emails.map((email) => (
<div
key={email.id}
onClick={() => loadEmailDetail(email.id)}
className={`p-4 cursor-pointer hover:bg-gray-50 ${
selectedEmail?.id === email.id ? 'bg-blue-50' : ''
}`}
>
<div className="font-medium text-sm mb-1">{email.subject}</div>
<div className="text-xs text-gray-600 mb-1">{email.from}</div>
<div className="text-xs text-gray-500">{email.snippet}</div>
<div className="text-xs text-gray-400 mt-1">{email.date}</div>
</div>
))}
</div>
</div>
{/* Email Detail and Reply */}
<div className="bg-white border border-gray-200 rounded-lg">
<div className="p-4 border-b border-gray-200">
<h2 className="text-lg font-semibold">Email Details</h2>
</div>
{selectedEmail ? (
<div className="p-4">
<div className="mb-4 pb-4 border-b border-gray-200">
<div className="mb-2">
<span className="font-medium">Subject:</span> {selectedEmail.subject}
</div>
<div className="mb-2 text-sm">
<span className="font-medium">From:</span> {selectedEmail.from}
</div>
<div className="mb-2 text-sm">
<span className="font-medium">To:</span> {selectedEmail.to}
</div>
<div className="text-sm text-gray-500">
<span className="font-medium">Date:</span> {selectedEmail.date}
</div>
</div>
<div className="mb-4 p-4 bg-gray-50 rounded max-h-[200px] overflow-y-auto">
<pre className="whitespace-pre-wrap text-sm">{selectedEmail.body}</pre>
</div>
<div className="border-t border-gray-200 pt-4">
<h3 className="font-medium mb-2">Reply</h3>
<textarea
value={replyText}
onChange={(e) => setReplyText(e.target.value)}
placeholder="Type your reply here..."
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 mb-2"
rows={6}
disabled={sendingReply}
/>
<button
onClick={sendReply}
disabled={sendingReply || !replyText.trim()}
className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:bg-gray-400 disabled:cursor-not-allowed"
>
{sendingReply ? 'Sending...' : 'Send Reply'}
</button>
</div>
</div>
) : (
<div className="p-8 text-center text-gray-500">
Select an email to view details and reply
</div>
)}
</div>
</div>
</div>
);
}

View file

@ -1,3 +1,4 @@
import Link from 'next/link';
import { Suspense } from 'react';
import { getIntegrationsApiV1IntegrationGet } from "@/client/sdk.gen";
@ -65,6 +66,9 @@ async function IntegrationList() {
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Created At
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Actions
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
@ -88,6 +92,16 @@ async function IntegrationList() {
minute: '2-digit'
})}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{integration.provider === 'google-mail' && (
<Link
href={`/integrations/${integration.id}/gmail`}
className="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
>
Search
</Link>
)}
</td>
</tr>
))}
</tbody>