init: scripts diversos (crawlers, conversores, scrapers)

This commit is contained in:
2026-03-05 20:38:36 +00:00
commit 6ac6f4be2a
925 changed files with 850330 additions and 0 deletions

25
email-cleaner/.gitignore vendored Executable file
View File

@@ -0,0 +1,25 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
.vercel

112
email-cleaner/App.tsx Executable file
View File

@@ -0,0 +1,112 @@
/**
* App.tsx
*
* @author Descomplicar® Crescimento Digital
* @link https://descomplicar.pt
* @copyright 2025 Descomplicar®
*/
import React, { useState, useEffect } from 'react';
import Header from './components/Header';
import Sidebar from './components/Sidebar';
import Dashboard from './components/Dashboard';
import Accounts from './components/Accounts';
import Settings from './components/Settings';
import type { Account, CrmSettings } from './types';
const App: React.FC = () => {
const [theme, setTheme] = useState(() => {
const savedTheme = localStorage.getItem('theme');
const userPrefersDark = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches;
return savedTheme || (userPrefersDark ? 'dark' : 'light');
});
const [activeView, setActiveView] = useState('dashboard');
const [accounts, setAccounts] = useState<Account[]>([
{ id: 1, email: 'usuario@gmail.com', provider: 'gmail', avatar: 'https://i.pravatar.cc/40?img=3' }
]);
const [crmSettings, setCrmSettings] = useState<CrmSettings>(() => {
const savedSettings = localStorage.getItem('crmSettings');
const defaults = {
url: '',
token: '',
autoDeleteTickets: false,
};
return savedSettings ? { ...defaults, ...JSON.parse(savedSettings) } : defaults;
});
const [isAutoCleanEnabled, setIsAutoCleanEnabled] = useState<boolean>(() => {
const savedState = localStorage.getItem('autoCleanEnabled');
return savedState ? JSON.parse(savedState) : false;
});
const [apiKey, setApiKey] = useState<string>(() => localStorage.getItem('geminiApiKey') || '');
useEffect(() => {
const root = window.document.documentElement;
if (theme === 'dark') {
root.classList.add('dark');
} else {
root.classList.remove('dark');
}
localStorage.setItem('theme', theme);
}, [theme]);
useEffect(() => {
localStorage.setItem('autoCleanEnabled', JSON.stringify(isAutoCleanEnabled));
}, [isAutoCleanEnabled]);
const handleSaveCrmSettings = (settings: CrmSettings) => {
setCrmSettings(settings);
localStorage.setItem('crmSettings', JSON.stringify(settings));
};
const handleSaveApiKey = (key: string) => {
setApiKey(key);
localStorage.setItem('geminiApiKey', key);
};
const toggleTheme = () => {
setTheme(prevTheme => (prevTheme === 'light' ? 'dark' : 'light'));
};
const addAccount = (account: Omit<Account, 'id' | 'avatar'>) => {
const newAccount: Account = {
id: Date.now(),
...account,
avatar: `https://i.pravatar.cc/40?img=${Math.floor(Math.random() * 70)}`
};
setAccounts(prev => [...prev, newAccount]);
};
const removeAccount = (accountId: number) => {
setAccounts(prev => prev.filter(acc => acc.id !== accountId));
};
return (
<div className="min-h-screen text-slate-800 dark:text-slate-200">
<Header theme={theme} toggleTheme={toggleTheme} />
<div className="flex">
<Sidebar activeView={activeView} setActiveView={setActiveView} />
<main className="flex-1 p-4 sm:p-6 lg:p-8 bg-slate-50 dark:bg-slate-900">
{activeView === 'dashboard' && <Dashboard crmSettings={crmSettings} isAutoCleanEnabled={isAutoCleanEnabled} apiKey={apiKey} />}
{activeView === 'contas' && <Accounts accounts={accounts} addAccount={addAccount} removeAccount={removeAccount} />}
{activeView === 'configuracoes' &&
<Settings
crmSettings={crmSettings}
onSaveCrmSettings={handleSaveCrmSettings}
isAutoCleanEnabled={isAutoCleanEnabled}
setAutoCleanEnabled={setIsAutoCleanEnabled}
apiKey={apiKey}
onSaveApiKey={handleSaveApiKey}
/>
}
</main>
</div>
</div>
);
};
export default App;

80
email-cleaner/README.md Executable file
View File

@@ -0,0 +1,80 @@
# Limpador de E-mail com IA - Guia de Implementação Backend
Este guia explica como implementar a automação de backend para que a limpeza de e-mails funcione 24/7, utilizando Google Cloud Run e Cloud Scheduler.
## Arquitetura
A aplicação agora consiste em duas partes principais:
1. **Frontend (Interface do Usuário):** O que você vê e interage no navegador. Usado para configurar, visualizar resultados e acionar análises manuais.
2. **Backend (Lógica de Automação):** Um serviço sem servidor (`api/scan.ts`) que contém a lógica para analisar e-mails e limpar tickets. Este serviço é projetado para ser executado no Google Cloud Run.
3. **Cloud Scheduler (Gatilho):** Um serviço da Google Cloud que aciona (chama) o nosso backend em intervalos regulares (a cada 2 horas).
---
## Passo 1: Implementar o Backend no Google Cloud Run
1. **Pré-requisitos:**
* Tenha uma conta no [Google Cloud Platform](https://cloud.google.com/) com faturação ativa.
* Instale e configure o [Google Cloud SDK (gcloud CLI)](https://cloud.google.com/sdk/docs/install).
2. **Crie um arquivo `package.json`** na raiz do seu projeto com o seguinte conteúdo. Isto é necessário para que o Cloud Run saiba como iniciar o seu serviço.
```json
{
"name": "email-cleaner-backend",
"version": "1.0.0",
"main": "api/scan.js",
"scripts": {
"start": "node api/scan.js"
},
"dependencies": {
"@google/genai": "^1.16.0"
}
}
```
*Nota: Você precisará compilar o `api/scan.ts` para JavaScript (`api/scan.js`) antes de implementar, ou usar um builder que suporte TypeScript no Cloud Run.*
3. **Implemente o Serviço:**
* Abra o terminal na raiz do seu projeto.
* Execute o seguinte comando para implementar a função que está em `api/scan.ts`:
```bash
gcloud run deploy email-cleaner-service --source . --region=us-central1 --allow-unauthenticated
```
* Siga as instruções no terminal. O comando `--allow-unauthenticated` é usado para permitir que o Cloud Scheduler chame o seu serviço. Em produção, você pode configurar um método de autenticação mais seguro.
4. **Configure as Variáveis de Ambiente:**
* Após a implementação, vá para o [console do Cloud Run](https://console.cloud.google.com/run).
* Encontre o seu serviço (`email-cleaner-service`) e clique nele.
* Clique em "Editar e Implementar Nova Revisão".
* Vá para a aba **"Variáveis e Segredos"**.
* Adicione as seguintes variáveis de ambiente com os seus valores correspondentes:
* `GEMINI_API_KEY`: A sua chave da API do Google Gemini.
* `PERFEX_CRM_URL`: A URL da sua API do Perfex CRM.
* `PERFEX_CRM_TOKEN`: O seu token da API do Perfex CRM.
* `PERFEX_AUTO_DELETE_TICKETS`: `true` ou `false`.
---
## Passo 2: Configurar o Google Cloud Scheduler
1. **Vá para o Cloud Scheduler:**
* No console do Google Cloud, navegue para a secção [Cloud Scheduler](https://console.cloud.google.com/cloudscheduler).
2. **Crie uma Tarefa (Job):**
* Clique em "Criar Tarefa".
* **Nome:** Dê um nome, por exemplo, `run-email-scan`.
* **Frequência:** Defina a frequência usando a sintaxe cron. Para "a cada 2 horas", use: `0 */2 * * *`.
* **Fuso Horário:** Selecione o seu fuso horário.
* **Destino:** Selecione `HTTP`.
* **URL:** Cole a URL do seu serviço Cloud Run que foi gerada no passo anterior.
* **Método HTTP:** Selecione `POST`.
* **Corpo:** Deixe em branco.
* Clique em "Criar".
---
## Conclusão
É isso! Agora, o Cloud Scheduler irá chamar o seu serviço no Cloud Run a cada 2 horas. O serviço Cloud Run irá então executar a lógica de análise de e-mails e limpeza de tickets do CRM, de forma totalmente automática e independente do seu navegador.

80
email-cleaner/api/scan.ts Executable file
View File

@@ -0,0 +1,80 @@
/**
* scan.ts
*
* @author Descomplicar® Crescimento Digital
* @link https://descomplicar.pt
* @copyright 2025 Descomplicar®
*/
// Este arquivo representa o seu backend sem servidor (Serverless Function).
// Você deve implementá-lo em uma plataforma como Google Cloud Run, Vercel ou Netlify Functions.
// As credenciais (API Key, CRM Token) devem ser configuradas como variáveis de ambiente na sua plataforma de hospedagem.
import { classifyEmails } from '../services/geminiService';
import { deleteTicketsFromSendersInPerfex } from '../services/perfexCrmService';
import { mockEmails } from '../data/mockEmails';
import { EmailCategory, type CrmSettings } from '../types';
// Em um ambiente de servidor real, você precisaria de um framework como Express
// para lidar com requisições HTTP. Esta é uma simulação para fins de demonstração.
// A função `handleRequest` simula o ponto de entrada da sua função na nuvem.
async function handleRequest() {
console.log('Iniciando tarefa de limpeza automática...');
// 1. Ler configurações das variáveis de ambiente
const apiKey = process.env.GEMINI_API_KEY;
const crmSettings: CrmSettings = {
url: process.env.PERFEX_CRM_URL || '',
token: process.env.PERFEX_CRM_TOKEN || '',
autoDeleteTickets: process.env.PERFEX_AUTO_DELETE_TICKETS === 'true',
};
// Validação básica
if (!apiKey) {
console.error('Erro: A variável de ambiente GEMINI_API_KEY não está definida.');
return { success: false, message: 'Chave da API do Gemini não configurada no servidor.' };
}
try {
// 2. Classificar e-mails usando o serviço Gemini
const categorizedEmails = await classifyEmails(mockEmails, apiKey);
console.log('E-mails classificados com sucesso.');
// 3. Excluir tickets do CRM, se configurado
if (crmSettings.autoDeleteTickets && crmSettings.url && crmSettings.token) {
const spamAndNotificationEmails = [
...(categorizedEmails[EmailCategory.SPAM]?.emails || []),
...(categorizedEmails[EmailCategory.NOTIFICATIONS]?.emails || [])
];
const senderEmails = [...new Set(spamAndNotificationEmails.map(email => email.sender))];
if (senderEmails.length > 0) {
try {
const deletedCount = await deleteTicketsFromSendersInPerfex(crmSettings, senderEmails);
console.log(`${deletedCount} tickets foram excluídos do CRM.`);
} catch (err) {
console.error('Erro ao excluir tickets do CRM:', (err as Error).message);
}
} else {
console.log('Nenhum e-mail de spam ou notificação encontrado para exclusão de tickets.');
}
} else {
console.log('A exclusão automática de tickets está desativada ou o CRM não está configurado.');
}
console.log('Tarefa de limpeza automática concluída com sucesso.');
return { success: true, message: 'Limpeza automática executada com sucesso.' };
} catch (error) {
console.error('Ocorreu um erro durante a execução da tarefa de limpeza automática:', error);
return { success: false, message: 'Falha na execução da limpeza automática.' };
}
}
// Para simular a execução, você pode chamar a função.
// Numa implementação real, o seu provedor de nuvem (ex: Cloud Run) chamaria
// uma função exportada quando o endpoint HTTP fosse acionado.
// Ex: `export default async (req, res) => { ... }`
handleRequest();

88
email-cleaner/api/test-imap.ts Executable file
View File

@@ -0,0 +1,88 @@
/**
* test-imap.ts
*
* @author Descomplicar® Crescimento Digital
* @link https://descomplicar.pt
* @copyright 2025 Descomplicar®
*/
// Em uma plataforma como Vercel ou Netlify, este arquivo se tornaria um endpoint de API.
// Ex: POST /api/test-imap
import { ImapFlow } from 'imapflow';
import type { ImapAccount } from '../types';
// Esta função simula o handler de uma requisição de API.
// Em um ambiente real com Express, por exemplo, seria algo como:
// export default async (req: Request, res: Response) => { ... }
// Aqui, vamos simular o corpo da função.
const handler = async (account: Omit<ImapAccount, 'provider' | 'id' | 'avatar'>) => {
const { host, port, user, password, secure } = account;
if (!host || !port || !user || !password) {
// No mundo real, retornaríamos um status HTTP 400
return {
success: false,
message: 'Todos os campos (host, porta, usuário, senha) são obrigatórios.',
};
}
const client = new ImapFlow({
host,
port,
secure,
auth: {
user,
pass: password,
},
logger: false, // Mude para true para debug detalhado no console do servidor
});
try {
await client.connect();
await client.logout();
// No mundo real, retornaríamos um status HTTP 200
return {
success: true,
message: 'Conexão IMAP bem-sucedida!',
};
} catch (err) {
console.error(`Falha na conexão IMAP para ${user}@${host}:`, err);
let friendlyMessage = 'Falha na conexão IMAP. Verifique suas credenciais e configurações.';
if (err instanceof Error) {
if (err.message.includes('ENOTFOUND')) {
friendlyMessage = 'O host do servidor não foi encontrado. Verifique o endereço do host.';
} else if (err.message.includes('ECONNREFUSED')) {
friendlyMessage = 'A conexão foi recusada. Verifique a porta e as configurações de segurança (TLS/SSL).';
} else if (err.message.toLowerCase().includes('authentication failed')) {
friendlyMessage = 'Falha na autenticação. Verifique o usuário e a senha.';
}
}
// No mundo real, retornaríamos um status HTTP 500 ou 401
return {
success: false,
message: friendlyMessage,
};
}
};
// Para fins de teste, você pode simular uma chamada.
// Em um projeto real, o framework (Express/Next/Vercel) chamaria o handler
// com base no corpo da requisição (req.body).
//
// Exemplo de como o frontend chamaria esta API:
//
// const response = await fetch('/api/test-imap', {
// method: 'POST',
// headers: { 'Content-Type': 'application/json' },
// body: JSON.stringify({ host, port, user, password, secure })
// });
// const result = await response.json();
// alert(result.message);
export default handler;

View File

@@ -0,0 +1,94 @@
/**
* Accounts.tsx
*
* @author Descomplicar® Crescimento Digital
* @link https://descomplicar.pt
* @copyright 2025 Descomplicar®
*/
import React, { useState } from 'react';
import type { Account } from '../types';
import AddAccountModal from './AddAccountModal';
import { GmailIcon } from './icons/GmailIcon';
import { OutlookIcon } from './icons/OutlookIcon';
import { YahooIcon } from './icons/YahooIcon';
import { ImapIcon } from './icons/ImapIcon';
interface AccountsProps {
accounts: Account[];
addAccount: (account: Omit<Account, 'id' | 'avatar'>) => void;
}
const ProviderIcon = ({ provider }: { provider: Account['provider'] }) => {
switch (provider) {
case 'gmail':
return <GmailIcon className="w-6 h-6" />;
case 'outlook':
return <OutlookIcon className="w-6 h-6" />;
case 'yahoo':
return <YahooIcon className="w-6 h-6" />;
case 'imap':
return <ImapIcon className="w-6 h-6" />;
default:
return null;
}
};
const Accounts: React.FC<AccountsProps> = ({ accounts, addAccount }) => {
const [isModalOpen, setIsModalOpen] = useState(false);
const handleAddAccount = (account: Omit<Account, 'id' | 'avatar'>) => {
addAccount(account);
setIsModalOpen(false);
}
return (
<>
<div className="space-y-6">
<div className="flex justify-between items-center">
<div>
<h2 className="text-2xl font-bold text-slate-800 dark:text-white">Contas Conectadas</h2>
<p className="text-slate-500 dark:text-slate-400">Gerencie as contas de e-mail que você deseja limpar.</p>
</div>
<button
onClick={() => setIsModalOpen(true)}
className="bg-sky-500 text-white font-semibold py-2 px-5 rounded-lg hover:bg-sky-600 transition-colors flex items-center gap-2"
>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" className="w-5 h-5">
<path strokeLinecap="round" strokeLinejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
</svg>
Adicionar Nova Conta
</button>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{accounts.map(account => (
<div key={account.id} className="bg-white dark:bg-slate-800 rounded-xl shadow-md p-5 border border-slate-200 dark:border-slate-700 flex items-center gap-4">
<img src={account.avatar} alt={`Avatar de ${account.email}`} className="w-12 h-12 rounded-full" />
<div className="flex-1">
<p className="font-semibold text-slate-700 dark:text-slate-200 truncate">{account.email}</p>
<p className="text-sm text-slate-500 dark:text-slate-400 capitalize flex items-center gap-2">
<ProviderIcon provider={account.provider} />
{account.provider}
</p>
</div>
<button onClick={() => removeAccount(account.id)} className="p-2 rounded-full text-slate-400 hover:bg-slate-100 hover:text-red-500 dark:hover:bg-slate-700 transition-colors" aria-label="Remover conta">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" className="w-5 h-5">
<path strokeLinecap="round" strokeLinejoin="round" d="m14.74 9-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 0 1-2.244 2.077H8.084a2.25 2.25 0 0 1-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 0 0-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 0 1 3.478-.397m7.5 0v-.916c0-1.18-.91-2.134-2.09-2.201a51.964 51.964 0 0 0-3.32 0c-1.18.067-2.09 1.02-2.09 2.201v.916m7.5 0a48.667 48.667 0 0 0-7.5 0" />
</svg>
</button>
</div>
))}
</div>
</div>
<AddAccountModal
isOpen={isModalOpen}
onClose={() => setIsModalOpen(false)}
onAddAccount={handleAddAccount}
/>
</>
);
};
export default Accounts;

View File

@@ -0,0 +1,103 @@
/**
* AddAccountModal.tsx
*
* @author Descomplicar® Crescimento Digital
* @link https://descomplicar.pt
* @copyright 2025 Descomplicar®
*/
import React, { useState } from 'react';
import type { Account } from '../types';
import { GmailIcon } from './icons/GmailIcon';
import { OutlookIcon } from './icons/OutlookIcon';
import { YahooIcon } from './icons/YahooIcon';
import { ImapIcon } from './icons/ImapIcon';
import AddImapAccountForm from './AddImapAccountForm';
interface AddAccountModalProps {
isOpen: boolean;
onClose: () => void;
onAddAccount: (account: Omit<Account, 'id' | 'avatar'>) => void;
}
const AddAccountModal: React.FC<AddAccountModalProps> = ({ isOpen, onClose, onAddAccount }) => {
const [view, setView] = useState<'providers' | 'imapForm'>('providers');
if (!isOpen) return null;
const handleProviderClick = (provider: Account['provider']) => {
const email = `novo.usuario.${Math.floor(Math.random() * 1000)}@${provider}.com`;
onAddAccount({ email, provider });
};
const handleImapConnect = (email: string) => {
onAddAccount({ email, provider: 'imap' });
}
const providers: { name: Account['provider'], icon: React.ReactNode }[] = [
{ name: 'gmail', icon: <GmailIcon className="w-6 h-6" /> },
{ name: 'outlook', icon: <OutlookIcon className="w-6 h-6" /> },
{ name: 'yahoo', icon: <YahooIcon className="w-6 h-6 text-[#6001d2]" /> },
];
return (
<div
className="fixed inset-0 bg-black/60 backdrop-blur-sm z-50 flex items-center justify-center p-4"
aria-modal="true"
role="dialog"
onClick={onClose}
>
<div
className="bg-white dark:bg-slate-800 rounded-xl shadow-2xl p-6 w-full max-w-sm transform transition-all"
role="document"
onClick={(e) => e.stopPropagation()}
>
<div className="flex justify-between items-center mb-4">
<h3 className="text-lg font-semibold text-slate-900 dark:text-white" id="modal-title">
{view === 'providers' ? 'Conectar nova conta' : 'Conectar Conta IMAP'}
</h3>
<button onClick={onClose} className="p-1 rounded-full hover:bg-slate-100 dark:hover:bg-slate-700">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" className="w-6 h-6 text-slate-500">
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18 18 6M6 6l12 12" />
</svg>
</button>
</div>
{view === 'providers' ? (
<>
<p className="text-sm text-slate-500 dark:text-slate-400 mb-6">
Selecione seu provedor de e-mail para conectar sua conta com segurança.
</p>
<div className="space-y-3">
{providers.map(provider => (
<button
key={provider.name}
onClick={() => handleProviderClick(provider.name)}
className="w-full flex items-center gap-4 p-4 rounded-lg border border-slate-200 dark:border-slate-700 hover:bg-slate-50 dark:hover:bg-slate-700/50 transition-colors"
>
{provider.icon}
<span className="font-medium text-slate-700 dark:text-slate-200 capitalize">
Conectar com {provider.name}
</span>
</button>
))}
<button
onClick={() => setView('imapForm')}
className="w-full flex items-center gap-4 p-4 rounded-lg border border-slate-200 dark:border-slate-700 hover:bg-slate-50 dark:hover:bg-slate-700/50 transition-colors"
>
<ImapIcon className="w-6 h-6 text-slate-500" />
<span className="font-medium text-slate-700 dark:text-slate-200">
Conectar via IMAP
</span>
</button>
</div>
</>
) : (
<AddImapAccountForm onConnect={handleImapConnect} onBack={() => setView('providers')} />
)}
</div>
</div>
);
};
export default AddAccountModal;

View File

@@ -0,0 +1,127 @@
/**
* AddImapAccountForm.tsx
*
* @author Descomplicar® Crescimento Digital
* @link https://descomplicar.pt
* @copyright 2025 Descomplicar®
*/
import React, { useState } from 'react';
import Spinner from './Spinner'; // Certifique-se que este componente exista
interface AddImapAccountFormProps {
onConnect: (email: string, user: string, host: string, port: number, secure: boolean) => void;
onBack: () => void;
}
const AddImapAccountForm: React.FC<AddImapAccountFormProps> = ({ onConnect, onBack }) => {
const [email, setEmail] = useState('');
const [user, setUser] = useState('');
const [password, setPassword] = useState('');
const [host, setHost] = useState('');
const [port, setPort] = useState(993);
const [secure, setSecure] = useState(true);
const [testStatus, setTestStatus] = useState<'idle' | 'testing' | 'success' | 'error'>('idle');
const [testMessage, setTestMessage] = useState('');
const handleTestConnection = async () => {
setTestStatus('testing');
setTestMessage('');
// A API que criamos espera um corpo de requisição (body).
// Como estamos em um ambiente de desenvolvimento sem um servidor Node rodando,
// não podemos chamar a API diretamente. A lógica abaixo é como o frontend
// se comportaria se estivesse fazendo um fetch para /api/test-imap.
// Por enquanto, vamos simular a chamada e focar na UI.
try {
const response = await fetch('/api/test-imap', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ host, port, user, password, secure })
});
if (!response.ok) {
// Tenta extrair uma mensagem de erro amigável do corpo da resposta
const errorResult = await response.json().catch(() => null);
throw new Error(errorResult?.message || `Erro no servidor: ${response.statusText}`);
}
const result = await response.json();
if (result.success) {
setTestStatus('success');
setTestMessage(result.message);
} else {
setTestStatus('error');
setTestMessage(result.message || 'Ocorreu um erro desconhecido.');
}
} catch (error) {
setTestStatus('error');
setTestMessage(error instanceof Error ? error.message : 'Falha ao se comunicar com o servidor de teste.');
}
};
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (testStatus === 'success') {
onConnect(email, user, host, port, secure);
} else {
alert('Por favor, teste a conexão com sucesso antes de adicionar a conta.');
}
};
return (
<form onSubmit={handleSubmit} className="space-y-4">
{/* Campos do formulário... */}
<div>
<label htmlFor="email" className="block text-sm font-medium text-slate-700 dark:text-slate-300">Endereço de E-mail (para exibição)</label>
<input type="email" id="email" value={email} onChange={(e) => setEmail(e.target.value)} required className="mt-1 block w-full input" placeholder="seunome@dominio.com" />
</div>
<div>
<label htmlFor="user" className="block text-sm font-medium text-slate-700 dark:text-slate-300">Nome de Usuário (para login)</label>
<input type="text" id="user" value={user} onChange={(e) => setUser(e.target.value)} required className="mt-1 block w-full input" placeholder="geralmente o mesmo que o e-mail" />
</div>
<div>
<label htmlFor="password" className="block text-sm font-medium text-slate-700 dark:text-slate-300">Senha</label>
<input type="password" id="password" value={password} onChange={(e) => setPassword(e.target.value)} required className="mt-1 block w-full input" />
</div>
<div className="grid grid-cols-3 gap-4">
<div className="col-span-2">
<label htmlFor="host" className="block text-sm font-medium text-slate-700 dark:text-slate-300">Servidor IMAP</label>
<input type="text" id="host" value={host} onChange={(e) => setHost(e.target.value)} required className="mt-1 block w-full input" placeholder="imap.dominio.com" />
</div>
<div>
<label htmlFor="port" className="block text-sm font-medium text-slate-700 dark:text-slate-300">Porta</label>
<input type="number" id="port" value={port} onChange={(e) => setPort(parseInt(e.target.value))} required className="mt-1 block w-full input" />
</div>
</div>
<div className="flex items-center">
<input type="checkbox" id="secure" checked={secure} onChange={(e) => setSecure(e.target.checked)} className="h-4 w-4 rounded border-slate-300 text-sky-600 focus:ring-sky-500" />
<label htmlFor="secure" className="ml-2 block text-sm text-slate-900 dark:text-slate-300">Usar conexão segura (SSL/TLS)</label>
</div>
{testMessage && (
<div className={`text-sm p-3 rounded-md ${testStatus === 'success' ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800'}`}>
{testMessage}
</div>
)}
<div className="flex items-center justify-between gap-3 pt-2">
<button type="button" onClick={onBack} className="btn-secondary">Voltar</button>
<div className="flex items-center gap-3">
<button type="button" onClick={handleTestConnection} disabled={testStatus === 'testing'} className="btn-secondary flex items-center gap-2">
{testStatus === 'testing' && <Spinner className="w-4 h-4" />}
{testStatus === 'testing' ? 'Testando...' : 'Testar Conexão'}
</button>
<button type="submit" disabled={testStatus !== 'success'} className="btn-primary disabled:opacity-50 disabled:cursor-not-allowed">
Adicionar Conta
</button>
</div>
</div>
</form>
);
};
export default AddImapAccountForm;

View File

@@ -0,0 +1,8 @@
/**
* AutoCleanManager.tsx
*
* @author Descomplicar® Crescimento Digital
* @link https://descomplicar.pt
* @copyright 2025 Descomplicar®
*/

View File

@@ -0,0 +1,79 @@
/**
* ConfirmationModal.tsx
*
* @author Descomplicar® Crescimento Digital
* @link https://descomplicar.pt
* @copyright 2025 Descomplicar®
*/
import React from 'react';
interface ConfirmationModalProps {
isOpen: boolean;
onClose: () => void;
onConfirm: () => void;
categoryName: string;
emailCount: number;
}
const ConfirmationModal: React.FC<ConfirmationModalProps> = ({
isOpen,
onClose,
onConfirm,
categoryName,
emailCount,
}) => {
if (!isOpen) return null;
return (
<div
className="fixed inset-0 bg-black/60 backdrop-blur-sm z-50 flex items-center justify-center p-4"
aria-modal="true"
role="dialog"
>
<div className="bg-white dark:bg-slate-800 rounded-xl shadow-2xl p-6 w-full max-w-md transform transition-all"
role="document"
>
<div className="text-center">
<div className="mx-auto flex items-center justify-center h-12 w-12 rounded-full bg-red-100 dark:bg-red-900/50 mb-4">
<svg className="h-6 w-6 text-red-600 dark:text-red-400" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth="1.5" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z" />
</svg>
</div>
<h3 className="text-lg font-semibold text-slate-900 dark:text-white" id="modal-title">
Confirmar Limpeza
</h3>
<div className="mt-2">
<p className="text-sm text-slate-500 dark:text-slate-400">
Você tem certeza que deseja remover permanentemente{' '}
<span className="font-bold text-slate-700 dark:text-slate-200">{emailCount} e-mails</span> da categoria{' '}
<span className="font-bold text-slate-700 dark:text-slate-200">{categoryName}</span>?
</p>
<p className="text-xs text-slate-400 dark:text-slate-500 mt-1">
Esta ação não pode ser desfeita.
</p>
</div>
</div>
<div className="mt-6 flex justify-center gap-4">
<button
type="button"
className="w-full justify-center rounded-md border border-slate-300 dark:border-slate-600 px-4 py-2 bg-white dark:bg-slate-800 text-base font-medium text-slate-700 dark:text-slate-200 shadow-sm hover:bg-slate-50 dark:hover:bg-slate-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-sky-500 dark:focus:ring-offset-slate-900 sm:text-sm"
onClick={onClose}
>
Cancelar
</button>
<button
type="button"
className="w-full justify-center rounded-md border border-transparent px-4 py-2 bg-red-600 text-base font-medium text-white shadow-sm hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500 dark:focus:ring-offset-slate-900 sm:text-sm"
onClick={onConfirm}
>
Sim, Limpar
</button>
</div>
</div>
</div>
);
};
export default ConfirmationModal;

View File

@@ -0,0 +1,208 @@
/**
* Dashboard.tsx
*
* @author Descomplicar® Crescimento Digital
* @link https://descomplicar.pt
* @copyright 2025 Descomplicar®
*/
import React, { useState, useCallback } from 'react';
import { mockEmails } from '../data/mockEmails';
import { classifyEmails } from '../services/geminiService';
import { deleteTicketsFromSendersInPerfex } from '../services/perfexCrmService';
import type { CategorizedEmails, CrmSettings } from '../types';
import { EmailCategory } from '../types';
import EmailCategoryCard from './EmailCategoryCard';
import Spinner from './Spinner';
import ConfirmationModal from './ConfirmationModal';
interface DashboardProps {
crmSettings: CrmSettings;
isAutoCleanEnabled: boolean;
apiKey: string;
}
const Dashboard: React.FC<DashboardProps> = ({ crmSettings, isAutoCleanEnabled, apiKey }) => {
const [isScanning, setIsScanning] = useState(false);
const [categorizedEmails, setCategorizedEmails] = useState<CategorizedEmails | null>(null);
const [error, setError] = useState<string | null>(null);
const [isConfirmModalOpen, setIsConfirmModalOpen] = useState(false);
const [categoryToDelete, setCategoryToDelete] = useState<{name: string; count: number} | null>(null);
const [ticketDeletionStatus, setTicketDeletionStatus] = useState<{ message: string; type: 'success' | 'error' } | null>(null);
const handleScanInbox = useCallback(async () => {
setCategorizedEmails(null);
setIsScanning(true);
setError(null);
setTicketDeletionStatus(null);
try {
const result = await classifyEmails(mockEmails, apiKey);
setCategorizedEmails(result);
if (crmSettings.autoDeleteTickets && crmSettings.url && crmSettings.token) {
const spamAndNotificationEmails = [
...(result[EmailCategory.SPAM]?.emails || []),
...(result[EmailCategory.NOTIFICATIONS]?.emails || [])
];
const senderEmails = [...new Set(spamAndNotificationEmails.map(email => email.sender))];
if (senderEmails.length > 0) {
try {
const deletedCount = await deleteTicketsFromSendersInPerfex(crmSettings, senderEmails);
if (deletedCount > 0) {
setTicketDeletionStatus({ message: `${deletedCount} tickets de spam/notificações foram excluídos do CRM.`, type: 'success' });
}
} catch (err) {
setTicketDeletionStatus({ message: (err as Error).message, type: 'error' });
}
}
}
} catch (err) {
console.error(err);
setError('Falha ao analisar a caixa de entrada. A chave de API do Gemini pode estar ausente ou inválida.');
} finally {
setIsScanning(false);
}
}, [crmSettings, apiKey]);
const handleClearCategory = (category: string, emailCount: number) => {
setCategoryToDelete({ name: category, count: emailCount });
setIsConfirmModalOpen(true);
};
const confirmClearCategory = () => {
if(!categorizedEmails || !categoryToDelete) return;
const newCategories = { ...categorizedEmails };
delete newCategories[categoryToDelete.name];
setCategorizedEmails(newCategories);
setIsConfirmModalOpen(false);
setCategoryToDelete(null);
};
const getGreeting = () => {
const hour = new Date().getHours();
if (hour < 12) return 'Bom dia';
if (hour < 18) return 'Boa tarde';
return 'Boa noite';
}
const TicketDeletionToast = () => {
if (!ticketDeletionStatus) return null;
const isSuccess = ticketDeletionStatus.type === 'success';
return (
<div className={`${isSuccess ? 'bg-green-100 dark:bg-green-900/50 border-green-500 text-green-700 dark:text-green-300' : 'bg-red-100 dark:bg-red-900/50 border-red-500 text-red-700 dark:text-red-300'} border-l-4 p-4 rounded-md flex justify-between items-center`} role="alert">
<p>{ticketDeletionStatus.message}</p>
<button onClick={() => setTicketDeletionStatus(null)} className="p-1 rounded-full hover:bg-black/10">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" className="w-5 h-5"><path strokeLinecap="round" strokeLinejoin="round" d="M6 18 18 6M6 6l12 12" /></svg>
</button>
</div>
);
};
return (
<>
<div className="space-y-6">
<div>
<h2 className="text-2xl font-bold text-slate-800 dark:text-white">{getGreeting()}, Usuário!</h2>
<p className="text-slate-500 dark:text-slate-400">
{isAutoCleanEnabled
? 'A limpeza automática está ativa e a ser executada no servidor.'
: 'Vamos organizar sua caixa de entrada.'}
</p>
</div>
<TicketDeletionToast />
{isAutoCleanEnabled ? (
<div className="p-4 bg-sky-50 dark:bg-sky-900/50 rounded-xl border border-sky-200 dark:border-sky-800 flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4">
<div className="text-sm">
<p className="font-semibold text-sky-600 dark:text-sky-300">Automação Ativa</p>
<p className="text-slate-600 dark:text-slate-300 mt-1">
Sua caixa de entrada é verificada em segundo plano a cada 2 horas.
</p>
</div>
<button
onClick={() => handleScanInbox()}
disabled={isScanning}
className="bg-sky-500 text-white font-semibold py-2 px-5 rounded-lg hover:bg-sky-600 transition-colors disabled:bg-sky-300 disabled:cursor-not-allowed flex items-center gap-2 flex-shrink-0"
>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" className={`w-5 h-5 ${isScanning ? 'animate-spin' : ''}`}>
<path strokeLinecap="round" strokeLinejoin="round" d="M16.023 9.348h4.992v-.001a7.5 7.5 0 0 1-1.06 3.58a7.5 7.5 0 0 1-4.225 3.58a7.5 7.5 0 0 1-5.232 0a7.5 7.5 0 0 1-4.225-3.58a7.5 7.5 0 0 1-1.06-3.58m7.5 0v-4.5m0 4.5v4.5m0-4.5h4.5m-4.5 0h-4.5" />
</svg>
{isScanning ? 'Analisando...' : 'Forçar Análise Agora'}
</button>
</div>
) : (
<div className="p-6 bg-white dark:bg-slate-800 rounded-xl shadow-sm border border-slate-200 dark:border-slate-700 flex flex-col sm:flex-row items-center justify-between gap-4">
<div>
<h3 className="text-lg font-semibold text-slate-800 dark:text-white">Pronto para começar?</h3>
<p className="text-slate-500 dark:text-slate-400 mt-1">Clique para que a IA analise e organize sua caixa de entrada.</p>
</div>
<button
onClick={() => handleScanInbox()}
disabled={isScanning}
className="bg-sky-500 text-white font-semibold py-3 px-6 rounded-lg hover:bg-sky-600 transition-colors disabled:bg-sky-300 flex items-center gap-2 flex-shrink-0"
>
{isScanning ? 'Analisando...' : 'Analisar Caixa de Entrada'}
</button>
</div>
)}
{isScanning && !categorizedEmails &&(
<div className="flex flex-col items-center justify-center p-10 bg-white dark:bg-slate-800 rounded-xl shadow-sm border border-slate-200 dark:border-slate-700">
<Spinner />
<p className="mt-4 text-slate-600 dark:text-slate-300 animate-pulse">A IA está analisando seus e-mails...</p>
</div>
)}
{error && (
<div className="bg-red-100 dark:bg-red-900/50 border-l-4 border-red-500 text-red-700 dark:text-red-300 p-4 rounded-md" role="alert">
<p className="font-bold">Oops! Algo deu errado.</p>
<p>{error}</p>
</div>
)}
{categorizedEmails && Object.keys(categorizedEmails).length > 0 && (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{Object.entries(categorizedEmails).map(([category, data]) => (
<EmailCategoryCard
key={category}
category={category}
summary={data.summary}
emails={data.emails}
onClear={() => handleClearCategory(category, data.emails.length)}
/>
))}
</div>
)}
{categorizedEmails && Object.keys(categorizedEmails).length === 0 && !isScanning && (
<div className="text-center p-10 bg-white dark:bg-slate-800 rounded-xl shadow-sm border border-slate-200 dark:border-slate-700">
<div className="mx-auto w-16 h-16 bg-green-100 dark:bg-green-900/50 rounded-full flex items-center justify-center mb-4">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" className="w-8 h-8 text-green-500">
<path strokeLinecap="round" strokeLinejoin="round" d="m4.5 12.75 6 6 9-13.5" />
</svg>
</div>
<h3 className="text-lg font-semibold text-slate-800 dark:text-white">Caixa de entrada limpa!</h3>
<p className="text-slate-500 dark:text-slate-400 mt-1 mb-6">Você limpou todos os e-mails acionáveis.</p>
</div>
)}
</div>
<ConfirmationModal
isOpen={isConfirmModalOpen}
onClose={() => setIsConfirmModalOpen(false)}
onConfirm={confirmClearCategory}
categoryName={categoryToDelete?.name || ''}
emailCount={categoryToDelete?.count || 0}
/>
</>
);
};
export default Dashboard;

View File

@@ -0,0 +1,85 @@
/**
* EmailCategoryCard.tsx
*
* @author Descomplicar® Crescimento Digital
* @link https://descomplicar.pt
* @copyright 2025 Descomplicar®
*/
import React, { useState, useEffect } from 'react';
import type { Email } from '../types';
import { EmailCategory } from '../types';
interface EmailCategoryCardProps {
category: string;
summary: string;
emails: Email[];
onClear: () => void;
}
const categoryStyles: { [key in EmailCategory]?: { icon: string; bg: string; text: string; } } = {
[EmailCategory.PROMOTIONS]: { icon: '🛍️', bg: 'bg-indigo-100 dark:bg-indigo-900/50', text: 'text-indigo-600 dark:text-indigo-300' },
[EmailCategory.NOTIFICATIONS]: { icon: '🔔', bg: 'bg-amber-100 dark:bg-amber-900/50', text: 'text-amber-600 dark:text-amber-300' },
[EmailCategory.NEWSLETTERS]: { icon: '📰', bg: 'bg-cyan-100 dark:bg-cyan-900/50', text: 'text-cyan-600 dark:text-cyan-300' },
[EmailCategory.SPAM]: { icon: '🗑️', bg: 'bg-red-100 dark:bg-red-900/50', text: 'text-red-600 dark:text-red-300' },
};
const EmailCategoryCard: React.FC<EmailCategoryCardProps> = ({ category, summary, emails, onClear }) => {
const [isExpanded, setIsExpanded] = useState(false);
const [isVisible, setIsVisible] = useState(false);
useEffect(() => {
const timer = setTimeout(() => setIsVisible(true), 10);
return () => clearTimeout(timer);
}, []);
const styles = categoryStyles[category as EmailCategory] || { icon: '✉️', bg: 'bg-slate-100 dark:bg-slate-700', text: 'text-slate-600 dark:text-slate-300' };
if (emails.length === 0) return null;
return (
<div className={`bg-white dark:bg-slate-800 rounded-xl shadow-md border border-slate-200 dark:border-slate-700 overflow-hidden transition-all duration-500 hover:shadow-lg hover:-translate-y-1 ${isVisible ? 'opacity-100' : 'opacity-0'}`}>
<div className="p-5">
<div className="flex items-start justify-between">
<div>
<div className="flex items-center gap-3">
<span className={`text-2xl ${styles.bg} p-2 rounded-md`}>{styles.icon}</span>
<h3 className="text-lg font-bold text-slate-800 dark:text-white capitalize">{category.toLowerCase()}</h3>
</div>
<p className="text-slate-500 dark:text-slate-400 text-sm mt-2">{summary}</p>
</div>
<span className={`${styles.bg} ${styles.text} text-sm font-bold px-3 py-1 rounded-full`}>
{emails.length}
</span>
</div>
<div className="mt-4">
<button onClick={onClear} className="w-full bg-red-500 text-white font-semibold py-2 px-4 rounded-lg hover:bg-red-600 transition-colors flex items-center justify-center gap-2">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" className="w-5 h-5">
<path strokeLinecap="round" strokeLinejoin="round" d="m14.74 9-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 0 1-2.244 2.077H8.084a2.25 2.25 0 0 1-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 0 0-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 0 1 3.478-.397m7.5 0v-.916c0-1.18-.91-2.134-2.09-2.201a51.964 51.964 0 0 0-3.32 0c-1.18.067-2.09 1.02-2.09 2.201v.916m7.5 0a48.667 48.667 0 0 0-7.5 0" />
</svg>
Limpar {emails.length} e-mails
</button>
<button onClick={() => setIsExpanded(!isExpanded)} className="w-full mt-2 text-sm text-slate-500 dark:text-slate-400 hover:text-sky-500 dark:hover:text-sky-400">
{isExpanded ? 'Ocultar e-mails' : 'Rever e-mails'}
</button>
</div>
</div>
{isExpanded && (
<div className="bg-slate-50 dark:bg-slate-800/50 p-4 border-t border-slate-200 dark:border-slate-700 max-h-60 overflow-y-auto">
<ul className="space-y-3">
{emails.map(email => (
<li key={email.id} className="text-sm p-2 rounded-md bg-white dark:bg-slate-700/50">
<p className="font-semibold text-slate-700 dark:text-slate-200 truncate">{email.sender}</p>
<p className="text-slate-500 dark:text-slate-400 truncate">{email.subject}</p>
</li>
))}
</ul>
</div>
)}
</div>
);
};
export default EmailCategoryCard;

View File

@@ -0,0 +1,55 @@
/**
* Header.tsx
*
* @author Descomplicar® Crescimento Digital
* @link https://descomplicar.pt
* @copyright 2025 Descomplicar®
*/
import React from 'react';
import { MailIcon } from './icons/MailIcon';
interface HeaderProps {
theme: string;
toggleTheme: () => void;
}
const Header: React.FC<HeaderProps> = ({ theme, toggleTheme }) => {
return (
<header className="bg-white/80 dark:bg-slate-800/80 backdrop-blur-sm shadow-sm p-4 flex justify-between items-center border-b border-slate-200 dark:border-slate-700 sticky top-0 z-10">
<div className="flex items-center gap-3">
<div className="bg-sky-500 p-2 rounded-lg">
<MailIcon className="w-6 h-6 text-white" />
</div>
<h1 className="text-xl font-bold text-slate-800 dark:text-white">Limpador de E-mail com IA</h1>
</div>
<div className="flex items-center gap-4">
<button
onClick={toggleTheme}
className="p-2 rounded-full hover:bg-slate-100 dark:hover:bg-slate-700 transition-colors"
aria-label={theme === 'dark' ? 'Ativar modo claro' : 'Ativar modo escuro'}
>
{theme === 'dark' ? (
<svg xmlns="http://www.w3.org/2000/svg" className="h-6 w-6 text-amber-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z" />
</svg>
) : (
<svg xmlns="http://www.w3.org/2000/svg" className="h-6 w-6 text-slate-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z" />
</svg>
)}
</button>
<button className="p-2 rounded-full hover:bg-slate-100 dark:hover:bg-slate-700 transition-colors">
<svg xmlns="http://www.w3.org/2000/svg" className="h-6 w-6 text-slate-500 dark:text-slate-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" />
</svg>
</button>
<img src="https://i.pravatar.cc/40?img=3" alt="Avatar do usuário" className="w-10 h-10 rounded-full" />
</div>
</header>
);
};
export default Header;

View File

@@ -0,0 +1,8 @@
/**
* RegisterInvoicesModal.tsx
*
* @author Descomplicar® Crescimento Digital
* @link https://descomplicar.pt
* @copyright 2025 Descomplicar®
*/

View File

@@ -0,0 +1,195 @@
/**
* Settings.tsx
*
* @author Descomplicar® Crescimento Digital
* @link https://descomplicar.pt
* @copyright 2025 Descomplicar®
*/
import React, { useState, useEffect } from 'react';
import type { CrmSettings } from '../types';
interface SettingsProps {
crmSettings: CrmSettings;
onSaveCrmSettings: (settings: CrmSettings) => void;
isAutoCleanEnabled: boolean;
setAutoCleanEnabled: (enabled: boolean) => void;
apiKey: string;
onSaveApiKey: (key: string) => void;
}
const Settings: React.FC<SettingsProps> = ({
crmSettings,
onSaveCrmSettings,
isAutoCleanEnabled,
setAutoCleanEnabled,
apiKey,
onSaveApiKey
}) => {
const [currentCrmSettings, setCurrentCrmSettings] = useState<CrmSettings>(crmSettings);
const [currentApiKey, setCurrentApiKey] = useState<string>(apiKey);
const [showSuccess, setShowSuccess] = useState(false);
useEffect(() => {
setCurrentCrmSettings(crmSettings);
}, [crmSettings]);
useEffect(() => {
setCurrentApiKey(apiKey);
}, [apiKey]);
const handleCrmChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const { name, value, type, checked } = e.target;
setCurrentCrmSettings(prev => ({
...prev,
[name]: type === 'checkbox' ? checked : value
}));
};
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
onSaveCrmSettings(currentCrmSettings);
onSaveApiKey(currentApiKey);
setShowSuccess(true);
setTimeout(() => setShowSuccess(false), 3000);
};
return (
<div className="space-y-8 max-w-3xl mx-auto">
<div>
<h2 className="text-2xl font-bold text-slate-800 dark:text-white">Configurações</h2>
<p className="text-slate-500 dark:text-slate-400">Gerencie as integrações e preferências da aplicação.</p>
</div>
<div className="bg-white dark:bg-slate-800 rounded-xl shadow-md border border-slate-200 dark:border-slate-700">
<div className="p-6">
<h3 className="text-lg font-semibold text-slate-800 dark:text-white">Limpeza Automática (Backend)</h3>
<p className="text-slate-500 dark:text-slate-400 text-sm mt-1">
Ative para que um processo no servidor (Cloud Run) analise seus e-mails a cada 2 horas, mesmo com o navegador fechado.
</p>
</div>
<div className="bg-slate-50 dark:bg-slate-800/50 p-6 border-t border-slate-200 dark:border-slate-700 rounded-b-xl">
<div className="flex items-center justify-between">
<span className="font-medium text-slate-700 dark:text-slate-200">
{isAutoCleanEnabled ? 'Automação Ativada' : 'Automação Desativada'}
</span>
<button
onClick={() => setAutoCleanEnabled(!isAutoCleanEnabled)}
className={`${
isAutoCleanEnabled ? 'bg-sky-500' : 'bg-slate-300 dark:bg-slate-600'
} relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-sky-500 focus:ring-offset-2 dark:ring-offset-slate-800`}
role="switch"
aria-checked={isAutoCleanEnabled}
>
<span
className={`${
isAutoCleanEnabled ? 'translate-x-5' : 'translate-x-0'
} pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out`}
/>
</button>
</div>
<p className="text-xs text-slate-500 dark:text-slate-400 mt-3">
<strong>Nota:</strong> Para a automação funcionar, as chaves de API abaixo devem ser configuradas como **variáveis de ambiente** no seu serviço Cloud Run. Consulte o arquivo `README.md`.
</p>
</div>
</div>
<form onSubmit={handleSubmit}>
<div className="bg-white dark:bg-slate-800 rounded-xl shadow-md border border-slate-200 dark:border-slate-700 mb-8">
<div className="p-6 space-y-6">
<div>
<h3 className="text-lg font-semibold text-slate-800 dark:text-white">Chave da API do Google Gemini</h3>
<p className="text-slate-500 dark:text-slate-400 text-sm mt-1">Insira sua chave para habilitar a análise com IA nas verificações manuais.</p>
</div>
<div>
<label htmlFor="apiKey" className="block text-sm font-medium text-slate-700 dark:text-slate-300">
Sua Chave API
</label>
<input
type="password"
name="apiKey"
id="apiKey"
value={currentApiKey}
onChange={(e) => setCurrentApiKey(e.target.value)}
className="mt-1 block w-full px-3 py-2 bg-white dark:bg-slate-700 border border-slate-300 dark:border-slate-600 rounded-md shadow-sm placeholder-slate-400 focus:outline-none focus:ring-sky-500 focus:border-sky-500 sm:text-sm"
placeholder="Cole sua chave da API do Gemini aqui"
/>
</div>
</div>
</div>
<div className="bg-white dark:bg-slate-800 rounded-xl shadow-md border border-slate-200 dark:border-slate-700">
<div className="p-6 space-y-6">
<div>
<h3 className="text-lg font-semibold text-slate-800 dark:text-white">Integração com Perfex CRM</h3>
<p className="text-slate-500 dark:text-slate-400 text-sm mt-1">Conecte-se à sua instância do Perfex CRM para automatizar tarefas.</p>
</div>
<div className="space-y-4">
<div>
<label htmlFor="url" className="block text-sm font-medium text-slate-700 dark:text-slate-300">
URL da API do Perfex CRM
</label>
<input
type="url"
name="url"
id="url"
value={currentCrmSettings.url}
onChange={handleCrmChange}
className="mt-1 block w-full px-3 py-2 bg-white dark:bg-slate-700 border border-slate-300 dark:border-slate-600 rounded-md shadow-sm placeholder-slate-400 focus:outline-none focus:ring-sky-500 focus:border-sky-500 sm:text-sm"
placeholder="https://seu-crm.com"
/>
</div>
<div>
<label htmlFor="token" className="block text-sm font-medium text-slate-700 dark:text-slate-300">
Token da API
</label>
<input
type="password"
name="token"
id="token"
value={currentCrmSettings.token}
onChange={handleCrmChange}
className="mt-1 block w-full px-3 py-2 bg-white dark:bg-slate-700 border border-slate-300 dark:border-slate-600 rounded-md shadow-sm placeholder-slate-400 focus:outline-none focus:ring-sky-500 focus:border-sky-500 sm:text-sm"
placeholder="Seu token de API seguro"
/>
</div>
<div className="relative flex items-start">
<div className="flex h-5 items-center">
<input
id="autoDeleteTickets"
name="autoDeleteTickets"
type="checkbox"
checked={currentCrmSettings.autoDeleteTickets}
onChange={handleCrmChange}
className="h-4 w-4 rounded border-slate-300 text-sky-600 focus:ring-sky-500"
/>
</div>
<div className="ml-3 text-sm">
<label htmlFor="autoDeleteTickets" className="font-medium text-slate-700 dark:text-slate-200">
Excluir Tickets de Spam/Notificações
</label>
<p className="text-slate-500 dark:text-slate-400">Excluir automaticamente tickets do CRM criados por e-mails de spam ou notificações.</p>
</div>
</div>
</div>
</div>
<div className="bg-slate-50 dark:bg-slate-800/50 p-4 border-t border-slate-200 dark:border-slate-700 rounded-b-xl flex items-center justify-end gap-4">
{showSuccess && <p className="text-sm text-green-600 dark:text-green-400 animate-pulse">Configurações salvas com sucesso!</p>}
<button
type="submit"
className="bg-sky-500 text-white font-semibold py-2 px-5 rounded-lg hover:bg-sky-600 transition-colors"
>
Salvar Todas as Configurações
</button>
</div>
</div>
</form>
</div>
);
};
export default Settings;

View File

@@ -0,0 +1,56 @@
/**
* Sidebar.tsx
*
* @author Descomplicar® Crescimento Digital
* @link https://descomplicar.pt
* @copyright 2025 Descomplicar®
*/
import React from 'react';
import { DashboardIcon } from './icons/DashboardIcon';
import { SettingsIcon } from './icons/SettingsIcon';
import { AccountIcon } from './icons/AccountIcon';
import { HelpIcon } from './icons/HelpIcon';
interface SidebarProps {
activeView: string;
setActiveView: (view: string) => void;
}
const Sidebar: React.FC<SidebarProps> = ({ activeView, setActiveView }) => {
const navItems = [
{ id: 'dashboard', name: 'Dashboard', icon: <DashboardIcon className="w-5 h-5" /> },
{ id: 'contas', name: 'Contas', icon: <AccountIcon className="w-5 h-5" /> },
{ id: 'configuracoes', name: 'Configurações', icon: <SettingsIcon className="w-5 h-5" /> },
{ id: 'ajuda', name: 'Ajuda', icon: <HelpIcon className="w-5 h-5" /> },
];
return (
<aside className="w-64 bg-white dark:bg-slate-800/50 p-4 border-r border-slate-200 dark:border-slate-700 hidden md:block">
<nav className="flex flex-col gap-2">
{navItems.map(item => (
<a
key={item.id}
href="#"
onClick={(e) => {
e.preventDefault();
setActiveView(item.id);
}}
className={`flex items-center gap-3 px-4 py-2.5 rounded-lg text-sm font-medium transition-colors ${
activeView === item.id
? 'bg-sky-500 text-white'
: 'text-slate-600 dark:text-slate-300 hover:bg-slate-100 dark:hover:bg-slate-700'
}`}
>
{item.icon}
<span>{item.name}</span>
</a>
))}
</nav>
</aside>
);
};
export default Sidebar;

View File

@@ -0,0 +1,18 @@
/**
* Spinner.tsx
*
* @author Descomplicar® Crescimento Digital
* @link https://descomplicar.pt
* @copyright 2025 Descomplicar®
*/
import React from 'react';
const Spinner: React.FC = () => {
return (
<div className="w-12 h-12 border-4 border-t-4 border-slate-200 dark:border-slate-600 border-t-sky-500 rounded-full animate-spin"></div>
);
};
export default Spinner;

View File

@@ -0,0 +1,16 @@
/**
* AccountIcon.tsx
*
* @author Descomplicar® Crescimento Digital
* @link https://descomplicar.pt
* @copyright 2025 Descomplicar®
*/
import React from 'react';
export const AccountIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => (
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" {...props}>
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 6a3.75 3.75 0 1 1-7.5 0 3.75 3.75 0 0 1 7.5 0ZM4.501 20.118a7.5 7.5 0 0 1 14.998 0A17.933 17.933 0 0 1 12 21.75c-2.676 0-5.216-.584-7.499-1.632Z" />
</svg>
);

View File

@@ -0,0 +1,16 @@
/**
* DashboardIcon.tsx
*
* @author Descomplicar® Crescimento Digital
* @link https://descomplicar.pt
* @copyright 2025 Descomplicar®
*/
import React from 'react';
export const DashboardIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => (
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" {...props}>
<path strokeLinecap="round" strokeLinejoin="round" d="m2.25 12 8.954-8.955c.44-.439 1.152-.439 1.591 0L21.75 12M4.5 9.75v10.125c0 .621.504 1.125 1.125 1.125H9.75v-4.875c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125V21h4.125c.621 0 1.125-.504 1.125-1.125V9.75M8.25 21h7.5" />
</svg>
);

View File

@@ -0,0 +1,21 @@
/**
* GmailIcon.tsx
*
* @author Descomplicar® Crescimento Digital
* @link https://descomplicar.pt
* @copyright 2025 Descomplicar®
*/
import React from 'react';
export const GmailIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => (
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" {...props}>
<path d="M22 6.5H2V17.5H22V6.5Z" fill="#4285F4"/>
<path d="M2.22217 6.64185L12 14.5L21.7778 6.64185C21.6163 6.55198 21.4173 6.5 21.2001 6.5H2.80006C2.5828 6.5 2.38378 6.55198 2.22217 6.64185Z" fill="#34A853"/>
<path d="M2 17.5L9.5 11.5L12 13.5L14.5 11.5L22 17.5V6.5L12 14.5L2 6.5V17.5Z" fill="#EA4335"/>
<path d="M2 17.5H22V16.5L12 8.5L2 16.5V17.5Z" fill="#FBBC05"/>
<path d="M22 6.5V17.5L14.5 11.5L22 6.5Z" fill="#C5221F"/>
<path d="M2 6.5V17.5L9.5 11.5L2 6.5Z" fill="#1E8E3E"/>
</svg>
);

View File

@@ -0,0 +1,16 @@
/**
* HelpIcon.tsx
*
* @author Descomplicar® Crescimento Digital
* @link https://descomplicar.pt
* @copyright 2025 Descomplicar®
*/
import React from 'react';
export const HelpIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => (
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" {...props}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9.879 7.519c1.171-1.025 3.071-1.025 4.242 0 1.172 1.025 1.172 2.687 0 3.712-.203.179-.43.326-.67.442-.745.361-1.45.999-1.45 1.827v.75M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Zm-9 5.25h.008v.008H12v-.008Z" />
</svg>
);

View File

@@ -0,0 +1,15 @@
/**
* ImapIcon.tsx
*
* @author Descomplicar® Crescimento Digital
* @link https://descomplicar.pt
* @copyright 2025 Descomplicar®
*/
import React from 'react';
export const ImapIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => (
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" {...props}>
<path strokeLinecap="round" strokeLinejoin="round" d="M21.75 6.75v10.5a2.25 2.25 0 0 1-2.25 2.25h-15a2.25 2.25 0 0 1-2.25-2.25V6.75m19.5 0A2.25 2.25 0 0 0 19.5 4.5h-15a2.25 2.25 0 0 0-2.25 2.25m19.5 0v.243a2.25 2.25 0 0 1-1.07 1.916l-7.5 4.615a2.25 2.25 0 0 1-2.36 0L3.32 8.91a2.25 2.25 0 0 1-1.07-1.916V6.75" />
</svg>
);

View File

@@ -0,0 +1,16 @@
/**
* MailIcon.tsx
*
* @author Descomplicar® Crescimento Digital
* @link https://descomplicar.pt
* @copyright 2025 Descomplicar®
*/
import React from 'react';
export const MailIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => (
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" {...props}>
<path strokeLinecap="round" strokeLinejoin="round" d="M21.75 9v.906a2.25 2.25 0 0 1-1.183 1.981l-6.478 3.488a2.25 2.25 0 0 1-2.18 0l-6.478-3.488A2.25 2.25 0 0 1 2.25 9.906V9m19.5 0a2.25 2.25 0 0 0-2.25-2.25H4.5A2.25 2.25 0 0 0 2.25 9m19.5 0v.906a2.25 2.25 0 0 1-1.183 1.981l-6.478 3.488a2.25 2.25 0 0 1-2.18 0l-6.478-3.488A2.25 2.25 0 0 1 2.25 9.906V9" />
</svg>
);

View File

@@ -0,0 +1,18 @@
/**
* OutlookIcon.tsx
*
* @author Descomplicar® Crescimento Digital
* @link https://descomplicar.pt
* @copyright 2025 Descomplicar®
*/
import React from 'react';
export const OutlookIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => (
<svg xmlns="http://www.w.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" {...props}>
<path d="M14 3H4C3.44772 3 3 3.44772 3 4V20C3 20.5523 3.44772 21 4 21H14C14.5523 21 15 20.5523 15 20V4C15 3.44772 14.5523 3 14 3Z" fill="#0072C6"/>
<path d="M20.5 7.5H15V16.5H20.5C20.7761 16.5 21 16.2761 21 16V8C21 7.72386 20.7761 7.5 20.5 7.5Z" fill="#0072C6"/>
<path d="M15 8.5V15.5L18.5 12L15 8.5Z" fill="white"/>
</svg>
);

View File

@@ -0,0 +1,16 @@
/**
* SettingsIcon.tsx
*
* @author Descomplicar® Crescimento Digital
* @link https://descomplicar.pt
* @copyright 2025 Descomplicar®
*/
import React from 'react';
export const SettingsIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => (
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" {...props}>
<path strokeLinecap="round" strokeLinejoin="round" d="M10.5 6h9.75M10.5 6a1.5 1.5 0 1 1-3 0m3 0a1.5 1.5 0 1 0-3 0M3.75 6H7.5m3 12h9.75m-9.75 0a1.5 1.5 0 0 1-3 0m3 0a1.5 1.5 0 0 0-3 0m-3.75 0H7.5m9-6h3.75m-3.75 0a1.5 1.5 0 0 1-3 0m3 0a1.5 1.5 0 0 0-3 0m-9.75 0h9.75" />
</svg>
);

View File

@@ -0,0 +1,16 @@
/**
* YahooIcon.tsx
*
* @author Descomplicar® Crescimento Digital
* @link https://descomplicar.pt
* @copyright 2025 Descomplicar®
*/
import React from 'react';
export const YahooIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => (
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="currentColor" {...props}>
<path d="M12.001 2C6.476 2 2 6.476 2 12s4.476 10 10.001 10C17.525 22 22 17.524 22 12S17.525 2 12.001 2zm3.33 13.064h-2.112l-2.08-4.116h-.032l-.56 4.116H8.441L7.1 6.936h2.128l1.6 4.316h.048l2.256-4.316h1.968l-2.32 3.84 2.553 5.288z"/>
</svg>
);

110
email-cleaner/data/mockEmails.ts Executable file
View File

@@ -0,0 +1,110 @@
/**
* mockEmails.ts
*
* @author Descomplicar® Crescimento Digital
* @link https://descomplicar.pt
* @copyright 2025 Descomplicar®
*/
import type { Email } from '../types';
export const mockEmails: Email[] = [
{
id: 1,
sender: 'GitHub',
subject: '[github/project] Pull request #123: New feature',
snippet: 'Hey, a new pull request has been submitted by user...',
timestamp: '2023-10-27T10:00:00Z',
},
{
id: 2,
sender: 'Loja de Roupas',
subject: '🔥 50% de Desconto em Toda a Loja! Não perca!',
snippet: 'As melhores ofertas da estação estão aqui. Clique para aproveitar...',
timestamp: '2023-10-27T09:30:00Z',
},
{
id: 3,
sender: 'TechCrunch Daily',
subject: 'As Últimas Notícias de Tecnologia',
snippet: 'Apple anuncia novo chip, startup de IA levanta $100M...',
timestamp: '2023-10-27T09:00:00Z',
},
{
id: 4,
sender: 'LinkedIn',
subject: 'Você apareceu em 15 pesquisas esta semana',
snippet: 'Veja quem está interessado no seu perfil profissional.',
timestamp: '2023-10-27T08:45:00Z',
},
{
id: 5,
sender: 'iFood',
subject: 'Seu almoço com R$15 de desconto',
snippet: 'Peça agora e aproveite este cupom exclusivo para você.',
timestamp: '2023-10-27T08:30:00Z',
},
{
id: 6,
sender: 'Medium Digest',
subject: 'Histórias recomendadas para você',
snippet: 'Descubra novos artigos sobre programação, design e produtividade.',
timestamp: '2023-10-27T08:00:00Z',
},
{
id: 7,
sender: 'Google Agenda',
subject: 'Notificação: Reunião de Equipe às 11:00',
snippet: 'Lembrete do seu próximo evento agendado.',
timestamp: '2023-10-27T07:55:00Z',
},
{
id: 8,
sender: 'Casas Bahia',
subject: 'Ofertas Imperdíveis de Eletrônicos!',
snippet: 'Smart TVs, smartphones e notebooks com preços incríveis.',
timestamp: '2023-10-26T22:00:00Z',
},
{
id: 9,
sender: 'Nerd-Letter',
subject: 'Resumo da Semana no Mundo Geek',
snippet: 'Novos trailers, reviews de jogos e muito mais.',
timestamp: '2023-10-26T20:00:00Z',
},
{
id: 10,
sender: 'Asana',
subject: 'A tarefa "Finalizar relatório" vence hoje',
snippet: 'Lembre-se de completar suas tarefas pendentes.',
timestamp: '2023-10-26T18:00:00Z',
},
{
id: 11,
sender: 'Amazon.com.br',
subject: 'Produtos que você pode gostar',
snippet: 'Baseado em suas últimas compras, encontramos itens para você.',
timestamp: '2023-10-26T15:10:00Z',
},
{
id: 12,
sender: 'The New York Times',
subject: 'Your Morning Briefing',
snippet: 'Here\'s what you need to know today...',
timestamp: '2023-10-26T12:00:00Z',
},
{
id: 13,
sender: 'Facebook',
subject: 'Você tem novas notificações',
snippet: 'Maria comentou na sua foto.',
timestamp: '2023-10-26T11:30:00Z',
},
{
id: 14,
sender: 'UX Design Weekly',
subject: 'Issue #354 of UX Design Weekly',
snippet: 'A hand-picked list of the best user experience design links.',
timestamp: '2023-10-26T10:00:00Z',
}
];

26
email-cleaner/index.html Executable file
View File

@@ -0,0 +1,26 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Limpador de E-mail com IA</title>
<script src="https://cdn.tailwindcss.com"></script>
<script type="importmap">
{
"imports": {
"react/": "https://aistudiocdn.com/react@^19.1.1/",
"react": "https://aistudiocdn.com/react@^19.1.1",
"react-dom/": "https://aistudiocdn.com/react-dom@^19.1.1/",
"@google/genai": "https://aistudiocdn.com/@google/genai@^1.16.0"
}
}
</script>
<link rel="stylesheet" href="/index.css">
</head>
<body class="bg-slate-50 dark:bg-slate-900">
<div id="root"></div>
<script type="module" src="/index.tsx"></script>
</body>
</html>

24
email-cleaner/index.tsx Executable file
View File

@@ -0,0 +1,24 @@
/**
* index.tsx
*
* @author Descomplicar® Crescimento Digital
* @link https://descomplicar.pt
* @copyright 2025 Descomplicar®
*/
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
const rootElement = document.getElementById('root');
if (!rootElement) {
throw new Error("Could not find root element to mount to");
}
const root = ReactDOM.createRoot(rootElement);
root.render(
<React.StrictMode>
<App />
</React.StrictMode>
);

5
email-cleaner/metadata.json Executable file
View File

@@ -0,0 +1,5 @@
{
"name": "Limpador de E-mail com IA",
"description": "Uma aplicação web inteligente que utiliza a IA do Gemini para analisar, categorizar e ajudar a limpar sua caixa de entrada de e-mails promocionais e notificações inúteis, mantendo apenas o que é importante.",
"requestFramePermissions": []
}

1686
email-cleaner/package-lock.json generated Executable file

File diff suppressed because it is too large Load Diff

22
email-cleaner/package.json Executable file
View File

@@ -0,0 +1,22 @@
{
"name": "limpador-de-e-mail-com-ia",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"@google/genai": "^1.16.0",
"imapflow": "^1.0.195",
"react": "^19.1.1",
"react-dom": "^19.1.1"
},
"devDependencies": {
"@types/node": "^22.14.0",
"typescript": "~5.8.2",
"vite": "^6.2.0"
}
}

View File

@@ -0,0 +1,112 @@
/**
* geminiService.ts
*
* @author Descomplicar® Crescimento Digital
* @link https://descomplicar.pt
* @copyright 2025 Descomplicar®
*/
import { GoogleGenAI, Type } from "@google/genai";
import type { Email, ClassifiedCategory, CategorizedEmails } from "../types";
import { EmailCategory } from "../types";
const MOCKED_RESPONSE: ClassifiedCategory[] = [
{
category: EmailCategory.PROMOTIONS,
summary: "Ofertas especiais e descontos de várias lojas online.",
email_ids: [2, 5, 8, 11]
},
{
category: EmailCategory.NOTIFICATIONS,
summary: "Alertas de login, atualizações de projetos e menções em redes sociais.",
email_ids: [1, 4, 7, 10, 13]
},
{
category: EmailCategory.NEWSLETTERS,
summary: "Boletins informativos de tecnologia, design e notícias gerais.",
email_ids: [3, 6, 9, 12, 14]
}
];
const processApiResponse = (
response: ClassifiedCategory[],
allEmails: Email[]
): CategorizedEmails => {
const categorized: CategorizedEmails = {};
for (const item of response) {
const categoryKey = item.category;
if (categoryKey !== EmailCategory.IMPORTANT && categoryKey !== EmailCategory.UNKNOWN) {
const emailsInCategory = item.email_ids.map(id =>
allEmails.find(email => email.id === id)
).filter((email): email is Email => email !== undefined);
if(emailsInCategory.length > 0) {
if (!categorized[categoryKey]) {
categorized[categoryKey] = { summary: item.summary, emails: [] };
}
categorized[categoryKey].summary = item.summary;
categorized[categoryKey].emails.push(...emailsInCategory);
}
}
}
return categorized;
};
export const classifyEmails = async (emails: Email[], apiKey: string): Promise<CategorizedEmails> => {
if (!apiKey) {
console.warn("API_KEY for Gemini is not provided. Using mocked data.");
await new Promise(resolve => setTimeout(resolve, 2000));
return processApiResponse(MOCKED_RESPONSE, emails);
}
const ai = new GoogleGenAI({ apiKey });
const emailPromptData = emails.map(e => ({
id: e.id,
from: e.sender,
subject: e.subject,
snippet: e.snippet
}));
const prompt = `
Você é um especialista em organização de e-mails. Analise a seguinte lista de e-mails em formato JSON.
1. Categorize cada e-mail em uma das seguintes categorias base: ${Object.values(EmailCategory).join(', ')}.
2. Agrupe os e-mails por categoria. Para cada categoria, forneça um breve resumo em português do conteúdo e uma lista dos IDs dos e-mails correspondentes.
3. NÃO inclua a categoria 'IMPORTANTE' no resultado final.
Aqui está a lista de e-mails:
${JSON.stringify(emailPromptData, null, 2)}
`;
try {
const response = await ai.models.generateContent({
model: "gemini-2.5-flash",
contents: prompt,
config: {
responseMimeType: "application/json",
responseSchema: {
type: Type.ARRAY,
items: {
type: Type.OBJECT,
properties: {
category: { type: Type.STRING },
summary: { type: Type.STRING },
email_ids: { type: Type.ARRAY, items: { type: Type.INTEGER } }
}
}
}
}
});
const classifiedData = JSON.parse(response.text);
return processApiResponse(classifiedData, emails);
} catch (error) {
console.error("Error calling Gemini API:", error);
throw new Error("Failed to classify emails with Gemini API.");
}
};

View File

@@ -0,0 +1,34 @@
/**
* perfexCrmService.ts
*
* @author Descomplicar® Crescimento Digital
* @link https://descomplicar.pt
* @copyright 2025 Descomplicar®
*/
import type { CrmSettings } from '../types';
/**
* Simula a exclusão de tickets no Perfex CRM com base nos remetentes de e-mail.
*/
export const deleteTicketsFromSendersInPerfex = (
settings: CrmSettings,
senderEmails: string[]
): Promise<number> => {
console.log('Iniciando exclusão de tickets de spam/notificações no Perfex CRM...');
console.log('Remetentes alvo:', senderEmails);
return new Promise((resolve, reject) => {
setTimeout(() => {
if (!settings.url || !settings.token) {
return reject(new Error('Não foi possível excluir tickets: URL do CRM ou Token não configurado.'));
}
if (settings.token === 'fail') {
return reject(new Error('Falha ao excluir tickets no Perfex CRM.'));
}
const simulatedDeletedCount = Math.floor(Math.random() * senderEmails.length) + 1;
console.log(`Sucesso! ${simulatedDeletedCount} tickets foram "excluídos" do Perfex CRM.`);
resolve(simulatedDeletedCount);
}, 1500);
});
};

29
email-cleaner/tsconfig.json Executable file
View File

@@ -0,0 +1,29 @@
{
"compilerOptions": {
"target": "ES2022",
"experimentalDecorators": true,
"useDefineForClassFields": false,
"module": "ESNext",
"lib": [
"ES2022",
"DOM",
"DOM.Iterable"
],
"skipLibCheck": true,
"types": [
"node"
],
"moduleResolution": "bundler",
"isolatedModules": true,
"moduleDetection": "force",
"allowJs": true,
"jsx": "react-jsx",
"paths": {
"@/*": [
"./*"
]
},
"allowImportingTsExtensions": true,
"noEmit": true
}
}

50
email-cleaner/types.ts Executable file
View File

@@ -0,0 +1,50 @@
/**
* types.ts
*
* @author Descomplicar® Crescimento Digital
* @link https://descomplicar.pt
* @copyright 2025 Descomplicar®
*/
export interface Email {
id: number;
sender: string;
subject: string;
snippet: string;
timestamp: string;
}
export enum EmailCategory {
PROMOTIONS = 'PROMOÇÕES',
NOTIFICATIONS = 'NOTIFICAÇÕES',
IMPORTANT = 'IMPORTANTE',
NEWSLETTERS = 'NEWSLETTERS',
SPAM = 'SPAM',
UNKNOWN = 'DESCONHECIDO'
}
export interface ClassifiedCategory {
category: EmailCategory | string;
summary: string;
email_ids: number[];
}
export interface CategorizedEmails {
[key: string]: {
summary: string;
emails: Email[];
};
}
export interface Account {
id: number;
email: string;
provider: 'gmail' | 'outlook' | 'yahoo' | 'imap';
avatar: string;
}
export interface CrmSettings {
url: string;
token: string;
autoDeleteTickets: boolean;
}

25
email-cleaner/vite.config.ts Executable file
View File

@@ -0,0 +1,25 @@
/**
* vite.config.ts
*
* @author Descomplicar® Crescimento Digital
* @link https://descomplicar.pt
* @copyright 2025 Descomplicar®
*/
import path from 'path';
import { defineConfig, loadEnv } from 'vite';
export default defineConfig(({ mode }) => {
const env = loadEnv(mode, '.', '');
return {
define: {
'process.env.API_KEY': JSON.stringify(env.GEMINI_API_KEY),
'process.env.GEMINI_API_KEY': JSON.stringify(env.GEMINI_API_KEY)
},
resolve: {
alias: {
'@': path.resolve(__dirname, '.'),
}
}
};
});