diff --git a/backend/.env b/backend/.env new file mode 100644 index 0000000..e16f982 --- /dev/null +++ b/backend/.env @@ -0,0 +1,11 @@ +DB_USER=postgres +DB_PASSWORD=M@tr!x#149@dm!N +DB_HOST=192.168.0.111 +DB_PORT=7925 +DB_NAME=ocr + +# Mail Configuration (Gmail) +MAIL_SERVER=imap.gmail.com +MAIL_USERNAME=matrixinfotech.it@gmail.com +MAIL_PASSWORD=qtxsthbxbisgcmqu +MAIL_PORT=993 diff --git a/backend/__pycache__/database.cpython-313.pyc b/backend/__pycache__/database.cpython-313.pyc new file mode 100644 index 0000000..5a00784 Binary files /dev/null and b/backend/__pycache__/database.cpython-313.pyc differ diff --git a/backend/__pycache__/mail_service.cpython-313.pyc b/backend/__pycache__/mail_service.cpython-313.pyc new file mode 100644 index 0000000..b74b8a4 Binary files /dev/null and b/backend/__pycache__/mail_service.cpython-313.pyc differ diff --git a/backend/__pycache__/main.cpython-313.pyc b/backend/__pycache__/main.cpython-313.pyc index 82c5b36..200e12e 100644 Binary files a/backend/__pycache__/main.cpython-313.pyc and b/backend/__pycache__/main.cpython-313.pyc differ diff --git a/backend/__pycache__/scheduler.cpython-313.pyc b/backend/__pycache__/scheduler.cpython-313.pyc new file mode 100644 index 0000000..c6a8d65 Binary files /dev/null and b/backend/__pycache__/scheduler.cpython-313.pyc differ diff --git a/backend/create_tables.py b/backend/create_tables.py new file mode 100644 index 0000000..add618d --- /dev/null +++ b/backend/create_tables.py @@ -0,0 +1,12 @@ +from database import engine, Base + +def create_tables(): + print("Creating tables in database...") + try: + Base.metadata.create_all(bind=engine) + print("Tables created successfully!") + except Exception as e: + print(f"Error creating tables: {e}") + +if __name__ == "__main__": + create_tables() diff --git a/backend/database.py b/backend/database.py new file mode 100644 index 0000000..1467745 --- /dev/null +++ b/backend/database.py @@ -0,0 +1,66 @@ +import os +from sqlalchemy import create_engine, Column, Integer, String, Text, DateTime, Boolean, ForeignKey, LargeBinary +from sqlalchemy.ext.declarative import declarative_base +from sqlalchemy.orm import sessionmaker, relationship +from dotenv import load_dotenv + +# Load environment variables +load_dotenv() + +DB_USER = os.getenv("DB_USER") +DB_PASSWORD = os.getenv("DB_PASSWORD") +DB_HOST = os.getenv("DB_HOST") +DB_PORT = os.getenv("DB_PORT") +import urllib.parse + +# ... (imports) + +# Load environment variables +load_dotenv() + +DB_USER = os.getenv("DB_USER") +DB_PASSWORD = os.getenv("DB_PASSWORD") +DB_HOST = os.getenv("DB_HOST") +DB_PORT = os.getenv("DB_PORT") +DB_NAME = os.getenv("DB_NAME") + +encoded_user = urllib.parse.quote_plus(DB_USER) +encoded_password = urllib.parse.quote_plus(DB_PASSWORD) + +# SQLAlchemy Database URL +DATABASE_URL = f"postgresql://{encoded_user}:{encoded_password}@{DB_HOST}:{DB_PORT}/{DB_NAME}" + +engine = create_engine(DATABASE_URL) +SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) + +Base = declarative_base() + +class Email(Base): + __tablename__ = "emails" + + id = Column(Integer, primary_key=True, index=True) + subject = Column(String, index=True) + sender = Column(String, index=True) + body = Column(Text) + received_date = Column(DateTime) + is_read = Column(Boolean, default=False) + + attachments = relationship("Attachment", back_populates="email") + +class Attachment(Base): + __tablename__ = "attachments" + + id = Column(Integer, primary_key=True, index=True) + email_id = Column(Integer, ForeignKey("emails.id")) + filename = Column(String) + content_type = Column(String) + file_content = Column(LargeBinary) # Storing content directly in DB as requested + + email = relationship("Email", back_populates="attachments") + +def get_db(): + db = SessionLocal() + try: + yield db + finally: + db.close() diff --git a/backend/mail_service.py b/backend/mail_service.py new file mode 100644 index 0000000..6827cb9 --- /dev/null +++ b/backend/mail_service.py @@ -0,0 +1,95 @@ +import os +import datetime +import mimetypes +import time +from imap_tools import MailBox, AND +from sqlalchemy.orm import Session +from database import SessionLocal, Email, Attachment +from dotenv import load_dotenv + +load_dotenv() + +MAIL_SERVER = os.getenv("MAIL_SERVER") +MAIL_USERNAME = os.getenv("MAIL_USERNAME") +MAIL_PASSWORD = os.getenv("MAIL_PASSWORD") +MAIL_PORT = int(os.getenv("MAIL_PORT", 993)) + +def fetch_and_store_emails(): + """ + Connects to Gmail, fetches UNREAD emails, stores them in DB, and marks as READ. + """ + print(f"[{datetime.datetime.now()}] Checking for new emails...") + + if not MAIL_USERNAME or not MAIL_PASSWORD or MAIL_USERNAME == "your-email@gmail.com": + print("Skipping Email Sync: Credentials not configured.") + return {"status": "skipped", "message": "Credentials not configured"} + + db: Session = SessionLocal() + count = 0 + + try: + # Connect to Inbox + # imap-tools MailBox defaults to SSL (port 993) if not specified, but we make it explicit here. + with MailBox(MAIL_SERVER, port=MAIL_PORT).login(MAIL_USERNAME, MAIL_PASSWORD, initial_folder='INBOX') as mailbox: + # Fetch UNREAD emails + for msg in mailbox.fetch(AND(seen=False)): + print(f"Processing Email: {msg.subject}") + + # Check if already exists (optional, but good practice if marking read fails) + # For now using simplistic approach: if we fetch it, we save it. + + new_email = Email( + subject=msg.subject, + sender=msg.from_, + body=msg.text or msg.html, + received_date=msg.date, + is_read=False # Consumed by app, but 'seen' on server + ) + db.add(new_email) + db.commit() # Commit to get ID + db.refresh(new_email) + + + # Process Attachments + for i, att in enumerate(msg.attachments): + filename = att.filename + if not filename: + # Fallback for missing filename + ext = mimetypes.guess_extension(att.content_type) or ".bin" + timestamp = int(time.time()) + filename = f"attachment_{timestamp}_{i}{ext}" + + print(f" - Attachment: {filename}") + new_attachment = Attachment( + email_id=new_email.id, + filename=filename, + content_type=att.content_type, + file_content=att.payload # raw bytes + ) + db.add(new_attachment) + + db.commit() + + # Mark as Read on Server (Auto-done by fetch unless bulk=True or mark_seen=False) + # imap-tools fetches are auto-seen by default. + count += 1 + + print(f"Sync Complete. {count} new emails.") + return {"status": "success", "count": count} + + except Exception as e: + error_msg = str(e) + print(f"Error fetching emails: {error_msg}") + + if "AUTHENTICATIONFAILED" in error_msg or "Invalid credentials" in error_msg: + return { + "status": "error", + "message": f"Authentication Failed: {error_msg}. \n[HINT]: If using Gmail, you MUST use an 'App Password', not your login password. Also ensure IMAP is enabled in Gmail Settings." + } + + return {"status": "error", "message": error_msg} + finally: + db.close() + +if __name__ == "__main__": + fetch_and_store_emails() diff --git a/backend/main.py b/backend/main.py index 7942579..02d793b 100644 --- a/backend/main.py +++ b/backend/main.py @@ -1,13 +1,32 @@ import io -from typing import Optional +import datetime +from typing import Optional, List +from contextlib import asynccontextmanager + from fastapi import FastAPI, File, UploadFile, HTTPException, Depends from fastapi.middleware.cors import CORSMiddleware from pydantic import BaseModel from pypdf import PdfReader import pytesseract from PIL import Image +from pdf2image import convert_from_bytes +from sqlalchemy.orm import Session -app = FastAPI() +# Internal modules +from database import get_db, Email +from scheduler import start_scheduler, stop_scheduler +from mail_service import fetch_and_store_emails + +# Lifespan for Scheduler +@asynccontextmanager +async def lifespan(app: FastAPI): + # Startup + start_scheduler() + yield + # Shutdown + stop_scheduler() + +app = FastAPI(lifespan=lifespan) # CORS configuration origins = [ @@ -38,13 +57,6 @@ class LoginResponse(BaseModel): class NERResponse(BaseModel): text: str -# 1. User Login Module (Dummy) -@app.post("/api/login", response_model=LoginResponse) -async def login(request: LoginRequest): - if request.username == "admin" and request.password == "admin": - return LoginResponse(token="dummy-jwt-token-123", message="Login Successful") - raise HTTPException(status_code=401, detail="Invalid credentials") - # 2. OCR Module def extract_text_from_pdf(file_bytes: bytes) -> str: try: @@ -68,10 +80,6 @@ def extract_text_from_image(file_bytes: bytes) -> str: print(f"Error reading Image: {e}") return "" -from pdf2image import convert_from_bytes - -# ... (imports) - @app.post("/api/ocr/extract", response_model=NERResponse) async def extract_text(file: UploadFile = File(...)): content = await file.read() @@ -100,6 +108,131 @@ async def extract_text(file: UploadFile = File(...)): return NERResponse(text=extracted_text) +import zipfile +import mimetypes +from fastapi.responses import StreamingResponse + +class EmailDTO(BaseModel): + id: int + subject: Optional[str] + sender: Optional[str] + received_date: Optional[datetime.datetime] + is_read: bool + has_attachments: bool = False + +class AttachmentDTO(BaseModel): + id: int + filename: str + content_type: str + +class EmailListResponse(BaseModel): + items: List[EmailDTO] + total: int + +class EmailDetailDTO(EmailDTO): + body: Optional[str] + attachments: List[AttachmentDTO] + +@app.get("/api/emails", response_model=EmailListResponse) +def get_emails(skip: int = 0, limit: int = 50, db: Session = Depends(get_db)): + total = db.query(Email).count() + emails = db.query(Email).order_by(Email.received_date.desc()).offset(skip).limit(limit).all() + + items = [ + EmailDTO( + id=e.id, + subject=e.subject, + sender=e.sender, + received_date=e.received_date, + is_read=e.is_read, + has_attachments=bool(e.attachments) # Check if list is not empty + ) for e in emails + ] + return EmailListResponse(items=items, total=total) + +# ... + +@app.get("/api/emails/{email_id}", response_model=EmailDetailDTO) +def get_email_details(email_id: int, db: Session = Depends(get_db)): + email = db.query(Email).filter(Email.id == email_id).first() + if not email: + raise HTTPException(status_code=404, detail="Email not found") + + # Mark as read if opened + if not email.is_read: + email.is_read = True + db.commit() + + # Safely handle missing content_type and filename + attachments = [] + for a in email.attachments: + fname = a.filename + if not fname or fname == "unknown": + ext = mimetypes.guess_extension(a.content_type or "") or ".bin" + fname = f"attachment_{a.id}{ext}" + + attachments.append( + AttachmentDTO( + id=a.id, + filename=fname, + content_type=a.content_type or "application/octet-stream" + ) + ) + + return EmailDetailDTO( + id=email.id, + subject=email.subject, + sender=email.sender, + received_date=email.received_date, + is_read=email.is_read, + has_attachments=bool(email.attachments), + body=email.body, + attachments=attachments + ) + +@app.get("/api/emails/{email_id}/download-all") +def download_all_attachments(email_id: int, db: Session = Depends(get_db)): + email = db.query(Email).filter(Email.id == email_id).first() + if not email: + raise HTTPException(status_code=404, detail="Email not found") + + if not email.attachments: + raise HTTPException(status_code=400, detail="No attachments found") + + # Create a zip file in memory + zip_buffer = io.BytesIO() + with zipfile.ZipFile(zip_buffer, "w", zipfile.ZIP_DEFLATED) as zip_file: + for att in email.attachments: + # Use filename from DB, ensure unique names if duplicates exist (simple append here) + zip_file.writestr(att.filename, att.file_content) + + zip_buffer.seek(0) + + return StreamingResponse( + zip_buffer, + media_type="application/zip", + headers={"Content-Disposition": f"attachment; filename=email-{email_id}-attachments.zip"} + ) + +@app.get("/api/attachments/{attachment_id}") +def download_attachment(attachment_id: int, view: bool = False, db: Session = Depends(get_db)): + attachment = db.query(DBAttachment).filter(DBAttachment.id == attachment_id).first() + if not attachment: + raise HTTPException(status_code=404, detail="Attachment not found") + + disposition = "inline" if view else "attachment" + + return StreamingResponse( + io.BytesIO(attachment.file_content), + media_type=attachment.content_type, + headers={"Content-Disposition": f"{disposition}; filename={attachment.filename}"} + ) + +@app.post("/api/emails/sync") +def sync_emails(): + result = fetch_and_store_emails() + return result + @app.get("/") def read_root(): return {"message": "OCR Backend API is running"} diff --git a/backend/requirements.txt b/backend/requirements.txt index 56c20f2..9f0da77 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -4,3 +4,8 @@ python-multipart pypdf pytesseract Pillow +sqlalchemy +psycopg2-binary +imap-tools +apscheduler +python-dotenv diff --git a/backend/scheduler.py b/backend/scheduler.py new file mode 100644 index 0000000..63e3b2c --- /dev/null +++ b/backend/scheduler.py @@ -0,0 +1,14 @@ +from apscheduler.schedulers.background import BackgroundScheduler +from mail_service import fetch_and_store_emails + +scheduler = BackgroundScheduler() + +def start_scheduler(): + # Add job to run every 10 seconds + scheduler.add_job(fetch_and_store_emails, 'interval', seconds=10, id='mail_sync_job', replace_existing=True) + scheduler.start() + print("Mail Scheduler Started (Interval: 10s)") + +def stop_scheduler(): + scheduler.shutdown() + print("Mail Scheduler Stopped") diff --git a/frontend/src/app/admin-layout/admin-layout.component.ts b/frontend/src/app/admin-layout/admin-layout.component.ts index aa5f5a7..37df214 100644 --- a/frontend/src/app/admin-layout/admin-layout.component.ts +++ b/frontend/src/app/admin-layout/admin-layout.component.ts @@ -51,6 +51,12 @@ export class AdminLayoutComponent implements OnInit { icon: 'pi pi-history' } ] + + }, + { + label: 'Mailbox', + icon: 'pi pi-envelope', + routerLink: '/mailbox' }, { label: 'Settings', diff --git a/frontend/src/app/app.routes.ts b/frontend/src/app/app.routes.ts index aff1440..8199137 100644 --- a/frontend/src/app/app.routes.ts +++ b/frontend/src/app/app.routes.ts @@ -2,6 +2,7 @@ import { Routes } from '@angular/router'; import { LoginComponent } from './login/login.component'; import { AdminLayoutComponent } from './admin-layout/admin-layout.component'; import { OcrComponent } from './ocr/ocr.component'; +import { MailboxComponent } from './mailbox/mailbox.component'; import { AuthGuard } from './auth.guard'; export const routes: Routes = [ @@ -12,7 +13,8 @@ export const routes: Routes = [ canActivate: [AuthGuard], children: [ { path: '', redirectTo: 'ocr', pathMatch: 'full' }, - { path: 'ocr', component: OcrComponent } + { path: 'ocr', component: OcrComponent }, + { path: 'mailbox', component: MailboxComponent } ] }, { path: '**', redirectTo: '' } diff --git a/frontend/src/app/mailbox.service.ts b/frontend/src/app/mailbox.service.ts new file mode 100644 index 0000000..31d424a --- /dev/null +++ b/frontend/src/app/mailbox.service.ts @@ -0,0 +1,59 @@ +import { Injectable } from '@angular/core'; +import { HttpClient } from '@angular/common/http'; +import { Observable } from 'rxjs'; + +export interface AttachmentDTO { + id: number; + filename: string; + content_type: string; +} + +export interface EmailDTO { + id: number; + subject: string; + sender: string; + received_date: string; + is_read: boolean; + has_attachments?: boolean; // New flag from backend + body?: string; + attachments?: AttachmentDTO[]; +} + +export interface EmailListResponse { + items: EmailDTO[]; + total: number; +} + +@Injectable({ + providedIn: 'root' +}) +export class MailboxService { + private apiUrl = 'http://localhost:8000/api/emails'; + private attachmentUrl = 'http://localhost:8000/api/attachments'; + + constructor(private http: HttpClient) {} + + getEmails(skip: number = 0, limit: number = 50): Observable { + return this.http.get(`${this.apiUrl}?skip=${skip}&limit=${limit}`); + } + + getEmailDetails(id: number): Observable { + return this.http.get(`${this.apiUrl}/${id}`); + } + + getAttachmentUrl(id: number): string { + return `${this.attachmentUrl}/${id}`; + } + + getPreviewUrl(id: number): string { + return `${this.attachmentUrl}/${id}?view=true`; + } + + getZipDownloadUrl(emailId: number): string { + return `${this.apiUrl}/${emailId}/download-all`; + } + + syncEmails(): Observable { + return this.http.post(`${this.apiUrl}/sync`, {}); + } +} diff --git a/frontend/src/app/mailbox/mailbox.component.ts b/frontend/src/app/mailbox/mailbox.component.ts new file mode 100644 index 0000000..15a004a --- /dev/null +++ b/frontend/src/app/mailbox/mailbox.component.ts @@ -0,0 +1,222 @@ +import { Component, OnInit } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { TableModule, TableLazyLoadEvent } from 'primeng/table'; +import { ButtonModule } from 'primeng/button'; +import { ToastModule } from 'primeng/toast'; +import { ProgressBarModule } from 'primeng/progressbar'; +import { DialogModule } from 'primeng/dialog'; +import { MessageService } from 'primeng/api'; +import { MailboxService, EmailDTO } from '../mailbox.service'; + +@Component({ + selector: 'app-mailbox', + standalone: true, + imports: [ + CommonModule, + TableModule, + ButtonModule, + ToastModule, + ProgressBarModule, + DialogModule + ], + providers: [MessageService], + template: ` +
+
+

Mailbox

+ +
+ + + + + + + ID + + Sender + Subject + Date + Status + Action + + + + + {{email.id}} + + + + {{email.sender}} + {{email.subject}} + {{email.received_date | date:'short'}} + + + + + + + + + + + No emails found. Click Sync to fetch from Gmail. + + + + + + + +
+
+ From: {{selectedEmail.sender}}
+ Subject: {{selectedEmail.subject}}
+ Date: {{selectedEmail.received_date | date:'medium'}}
+
+
+ + +
+
+

Attachments ({{selectedEmail.attachments.length}})

+ + Download All + +
+ +
+
+ + +
+ + + +
+ + + + + + + + + {{att.filename}} + +
+
+
+
+
+
+ `, + styles: [` + .font-bold { font-weight: 700; } + .email-body { max-height: 400px; overflow-y: auto; background: #f9f9f9; padding: 1rem; border-radius: 4px; } + .attachment-card:hover .download-overlay { opacity: 1; } + .download-overlay { transition: opacity 0.2s; } + `] +}) +export class MailboxComponent implements OnInit { + emails: EmailDTO[] = []; + totalRecords: number = 0; + loading: boolean = true; + syncing: boolean = false; + + displayDialog: boolean = false; + selectedEmail: EmailDTO | null = null; + + constructor( + private mailboxService: MailboxService, + private messageService: MessageService + ) {} + + ngOnInit() { + // Initial load handled by p-table lazy load + } + + loadEmails(event: TableLazyLoadEvent) { + this.loading = true; + const skip = event.first || 0; + const limit = event.rows || 50; + + this.mailboxService.getEmails(skip, limit).subscribe({ + next: (res) => { + this.emails = res.items; + this.totalRecords = res.total; + this.loading = false; + }, + error: (err) => { + console.error(err); + this.loading = false; + } + }); + } + + viewEmail(id: number) { + this.mailboxService.getEmailDetails(id).subscribe({ + next: (email) => { + this.selectedEmail = email; + this.displayDialog = true; + // Update local read status + const idx = this.emails.findIndex(e => e.id === id); + if(idx !== -1) { + this.emails[idx].is_read = true; + } + }, + error: (err) => this.messageService.add({severity:'error', summary:'Error', detail:'Could not load email details'}) + }); + } + + getDownloadLink(id: number): string { + return this.mailboxService.getAttachmentUrl(id); + } + + getPreviewLink(id: number): string { + return this.mailboxService.getPreviewUrl(id); + } + + getDownloadAllLink(emailId: number): string { + return this.mailboxService.getZipDownloadUrl(emailId); + } + + isImage(contentType: string): boolean { + return contentType.startsWith('image/'); + } + + isPdf(contentType: string): boolean { + return contentType === 'application/pdf'; + } + + sync() { + this.syncing = true; + this.mailboxService.syncEmails().subscribe({ + next: (res) => { + this.syncing = false; + this.messageService.add({severity:'success', summary:'Sync Complete', detail: res.message || 'Emails synced successfully'}); + // Refresh table + this.loadEmails({first: 0, rows: 50}); + }, + error: (err) => { + this.syncing = false; + this.messageService.add({severity:'error', summary:'Sync Failed', detail: 'Could not fetch emails'}); + } + }); + } +} diff --git a/frontend/src/app/ocr/ocr.component.ts b/frontend/src/app/ocr/ocr.component.ts index 75fab9c..192a56d 100644 --- a/frontend/src/app/ocr/ocr.component.ts +++ b/frontend/src/app/ocr/ocr.component.ts @@ -35,6 +35,7 @@ import { ToastModule } from 'primeng/toast'; cancelLabel="Clear" [customUpload]="true" (uploadHandler)="onUpload($event)" + (onClear)="onClear()" accept=".pdf,image/*" maxFileSize="10000000"> @@ -84,4 +85,8 @@ export class OcrComponent { } }); } + + onClear() { + this.extractedText = null; + } }