Coverage for modules\email\api.py: 23%

275 statements  

« prev     ^ index     » next       coverage.py v7.16.0, created at 2026-09-05 09:25 +0800

1""" 

2Email API - REST endpoints for email module 

3""" 

4from typing import List 

5from uuid import UUID 

6from ninja import Router, Query 

7from ninja.pagination import paginate 

8from django.shortcuts import get_object_or_404 

9from django.utils import timezone 

10from django.http import QueryDict 

11from core.auth import JWTAuth 

12from core.permissions import require_tenant 

13from core.pagination import StandardPagination 

14 

15from .models import EmailAccount, EmailFolder, EmailMessage, EmailAttachment, EmailFilter, InternalEmail, InternalEmailRecipient 

16from .schemas import ( 

17 EmailAccountOut, EmailAccountCreateIn, EmailAccountUpdateIn, 

18 EmailFolderOut, EmailFolderCreateIn, EmailMessageOut, EmailMessageDetailOut, EmailAttachmentOut, 

19 EmailSendIn, EmailDraftIn, EmailMessageUpdateIn, 

20 EmailFilterOut, EmailFilterCreateIn, EmailFilterCondition, 

21 InternalEmailOut, InternalEmailSendIn, InternalEmailCountOut, 

22) 

23from .services import EmailAccountService, EmailFolderService, EmailMessageService 

24 

25router = Router(tags=['email']) 

26 

27 

28# ============= Email Accounts ============= 

29 

30@router.get('/accounts', response=List[EmailAccountOut], auth=JWTAuth()) 

31def list_email_accounts(request): 

32 """List all connected email accounts.""" 

33 tenant_id = require_tenant(request) 

34 accounts = EmailAccount.objects.filter( 

35 tenant_id=tenant_id, 

36 user=request.user.id 

37 ) 

38 

39 result = [] 

40 for account in accounts: 

41 # Get unread inbox count using raw SQL to avoid ORM UUID issues 

42 from django.db import connection 

43 unread = 0 

44 with connection.cursor() as cursor: 

45 cursor.execute( 

46 "SELECT unread_count FROM email_emailfolder WHERE account_id = %s AND folder_type = 'inbox'", 

47 [str(account.id)] 

48 ) 

49 row = cursor.fetchone() 

50 if row: 

51 unread = row[0] 

52 

53 result.append({ 

54 'id': account.id, 

55 'provider': account.provider, 

56 'connection_type': account.connection_type, 

57 'email_address': account.email_address, 

58 'display_name': account.display_name, 

59 'is_primary': account.is_primary, 

60 'sync_enabled': account.sync_enabled, 

61 'last_sync_at': account.last_sync_at, 

62 'is_verified': account.is_verified, 

63 'unread_inbox_count': unread, 

64 }) 

65 

66 return result 

67 

68 

69@router.post('/accounts', response=EmailAccountOut, auth=JWTAuth()) 

70def create_email_account(request, data: EmailAccountCreateIn): 

71 """Connect a new email account (IMAP/SMTP).""" 

72 import traceback 

73 

74 # Write debug to file to ensure we see it 

75 with open('d:/fudo_debug.log', 'a') as f: 

76 f.write(f"[API] Request received\n") 

77 f.write(f"[API] Auth user: {request.user}\n") 

78 f.write(f"[API] Authenticated: {request.user.is_authenticated if hasattr(request.user, 'is_authenticated') else 'N/A'}\n") 

79 f.write(f"[API] Data: {data.model_dump()}\n") 

80 

81 tenant_id = require_tenant(request) 

82 

83 with open('d:/fudo_debug.log', 'a') as f: 

84 f.write(f"[API] Tenant ID: {tenant_id}\n") 

85 

86 try: 

87 account, success, message = EmailAccountService.create_imap_account( 

88 user=request.user.id, 

89 tenant_id=tenant_id, 

90 data=data.model_dump() 

91 ) 

92 with open('d:/fudo_debug.log', 'a') as f: 

93 f.write(f"[API] Service result: success={success}, message={message}\n") 

94 except Exception as e: 

95 with open('d:/fudo_debug.log', 'a') as f: 

96 f.write(f"[API] Exception: {type(e).__name__}: {str(e)}\n") 

97 f.write(traceback.format_exc()) 

98 raise 

99 

100 if not success: 

101 from ninja import HttpError 

102 raise HttpError(400, message) 

103 

104 return EmailAccountOut( 

105 id=account.id, 

106 provider=account.provider, 

107 connection_type=account.connection_type, 

108 email_address=account.email_address, 

109 display_name=account.display_name or "", 

110 is_primary=account.is_primary, 

111 sync_enabled=account.sync_enabled, 

112 last_sync_at=account.last_sync_at, 

113 is_verified=account.is_verified, 

114 unread_inbox_count=0, 

115 ) 

116 

117 

118@router.get('/test', auth=JWTAuth()) 

119def test_endpoint(request): 

120 """Test endpoint to check auth and tenant.""" 

121 tenant_id = require_tenant(request) 

122 return { 

123 'status': 'ok', 

124 'user_id': request.user.id, 

125 'tenant_id': str(tenant_id) if tenant_id else None, 

126 'email': request.user.email if hasattr(request.user, 'email') else 'no email', 

127 } 

128 

129 

130@router.patch('/accounts/{account_id}', response=EmailAccountOut, auth=JWTAuth()) 

131def update_email_account(request, account_id: UUID, data: EmailAccountUpdateIn): 

132 """Update email account settings.""" 

133 tenant_id = require_tenant(request) 

134 account = get_object_or_404( 

135 EmailAccount, 

136 id=account_id, 

137 tenant_id=tenant_id 

138 ) 

139 

140 for key, value in data.model_dump(exclude_none=True).items(): 

141 setattr(account, key, value) 

142 account.save() 

143 

144 return { 

145 'id': account.id, 

146 'provider': account.provider, 

147 'connection_type': account.connection_type, 

148 'email_address': account.email_address, 

149 'display_name': account.display_name, 

150 'is_primary': account.is_primary, 

151 'sync_enabled': account.sync_enabled, 

152 'last_sync_at': account.last_sync_at, 

153 'is_verified': account.is_verified, 

154 'unread_inbox_count': 0, 

155 } 

156 

157 

158@router.delete('/accounts/{account_id}', auth=JWTAuth()) 

159def delete_email_account(request, account_id: UUID): 

160 """Disconnect an email account.""" 

161 tenant_id = require_tenant(request) 

162 account = get_object_or_404( 

163 EmailAccount, 

164 id=account_id, 

165 tenant_id=tenant_id 

166 ) 

167 account.delete() 

168 return {'success': True} 

169 

170 

171@router.post('/accounts/{account_id}/sync', auth=JWTAuth()) 

172def sync_email_account(request, account_id: UUID): 

173 """Sync emails for an account.""" 

174 tenant_id = require_tenant(request) 

175 account = get_object_or_404( 

176 EmailAccount, 

177 id=account_id, 

178 tenant_id=tenant_id 

179 ) 

180 

181 success, message = EmailAccountService.sync_account(account) 

182 

183 if success: 

184 return {'success': True, 'message': message} 

185 else: 

186 return {'success': False, 'error': message} 

187 

188 

189# ============= Gmail OAuth ============= 

190 

191@router.get('/oauth/authorize', auth=JWTAuth()) 

192def gmail_oauth_authorize(request): 

193 """ 

194 Get Gmail OAuth authorization URL. 

195 Redirects user to Google to authorize access. 

196 """ 

197 try: 

198 from .gmail_oauth import GmailOAuthService 

199 oauth = GmailOAuthService() 

200 auth_url = oauth.get_authorization_url() 

201 return {'authorization_url': auth_url} 

202 except ImportError as e: 

203 return {'error': str(e)}, 500 

204 

205 

206@router.get('/oauth/callback', auth=JWTAuth()) 

207def gmail_oauth_callback(request, code: str = None, error: str = None): 

208 """ 

209 Handle Gmail OAuth callback. 

210 Exchanges authorization code for tokens and creates email account. 

211 """ 

212 if error: 

213 return {'success': False, 'error': error} 

214 

215 if not code: 

216 return {'success': False, 'error': 'No authorization code provided'} 

217 

218 try: 

219 from .gmail_oauth import GmailOAuthService 

220 oauth = GmailOAuthService() 

221 

222 if oauth.exchange_code_for_tokens(code): 

223 # Create email account with OAuth tokens 

224 tenant_id = require_tenant(request) 

225 account, success, message = EmailAccountService.create_oauth_account( 

226 user=request.user.id, 

227 tenant_id=tenant_id, 

228 credentials={ 

229 'token': oauth.creds.to_json() if oauth.creds else None, 

230 'refresh_token': oauth.creds.refresh_token if oauth.creds else None, 

231 } 

232 ) 

233 

234 if success: 

235 return { 

236 'success': True, 

237 'account_id': str(account.id), 

238 'message': 'Email account connected successfully' 

239 } 

240 else: 

241 return {'success': False, 'error': message} 

242 else: 

243 return {'success': False, 'error': 'Failed to exchange code for tokens'} 

244 except ImportError as e: 

245 return {'success': False, 'error': str(e)}, 500 

246 except Exception as e: 

247 return {'success': False, 'error': str(e)}, 500 

248 

249 

250# ============= Email Folders ============= 

251 

252@router.get('/folders', response=List[EmailFolderOut], auth=JWTAuth()) 

253def list_email_folders(request): 

254 """List folders for an email account.""" 

255 import uuid 

256 import sqlite3 

257 

258 tenant_id = require_tenant(request) 

259 account_id = request.GET.get('account_id') 

260 

261 if not account_id: 

262 return [] 

263 

264 # Convert account_id string to proper UUID format 

265 account_id_hex = account_id.replace('-', '') 

266 tenant_id_hex = tenant_id.hex if hasattr(tenant_id, 'hex') else str(tenant_id).replace('-', '') 

267 

268 # Use direct sqlite3 to avoid Django's debug SQL issues 

269 db_path = 'd:/FUDO/erp/apps/backend/db.sqlite3' 

270 with sqlite3.connect(db_path) as conn: 

271 cursor = conn.cursor() 

272 cursor.execute( 

273 """SELECT CAST(id AS TEXT), name, folder_type, unread_count FROM email_emailfolder  

274 WHERE tenant_id = ? AND account_id = ? 

275 ORDER BY folder_type, name""", 

276 [tenant_id_hex, account_id_hex] 

277 ) 

278 rows = cursor.fetchall() 

279 

280 return [ 

281 { 

282 'id': str(row[0]), # id is integer, CAST returns text 

283 'name': row[1], 

284 'folder_type': row[2], 

285 'unread_count': row[3], 

286 } 

287 for row in rows 

288 ] 

289 

290 

291@router.post('/folders', response=EmailFolderOut, auth=JWTAuth()) 

292def create_email_folder(request, data: EmailFolderCreateIn, account_id: UUID): 

293 """Create a custom email folder.""" 

294 tenant_id = require_tenant(request) 

295 account = get_object_or_404( 

296 EmailAccount, 

297 id=account_id, 

298 tenant_id=tenant_id 

299 ) 

300 

301 # Only allow creating 'custom' type folders via API 

302 if data.folder_type != 'custom': 

303 from core.exceptions import HttpError 

304 raise HttpError(400, "Only custom folders can be created via API") 

305 

306 # Check for duplicate name 

307 existing = EmailFolder.objects.filter( 

308 tenant_id=tenant_id, 

309 account=account, 

310 name=data.name 

311 ).first() 

312 if existing: 

313 from core.exceptions import HttpError 

314 raise HttpError(400, "A folder with this name already exists") 

315 

316 folder = EmailFolder.objects.create( 

317 tenant_id=tenant_id, 

318 account=account, 

319 name=data.name, 

320 folder_type='custom', 

321 unread_count=0 

322 ) 

323 

324 return { 

325 'id': folder.id, 

326 'name': folder.name, 

327 'folder_type': folder.folder_type, 

328 'unread_count': folder.unread_count, 

329 } 

330 

331 

332@router.delete('/folders/{folder_id}', auth=JWTAuth()) 

333def delete_email_folder(request, account_id: UUID, folder_id: UUID): 

334 """Delete a custom email folder.""" 

335 tenant_id = require_tenant(request) 

336 account = get_object_or_404( 

337 EmailAccount, 

338 id=account_id, 

339 tenant_id=tenant_id 

340 ) 

341 

342 folder = get_object_or_404( 

343 EmailFolder, 

344 id=folder_id, 

345 account=account, 

346 tenant_id=tenant_id 

347 ) 

348 

349 # Cannot delete system folders 

350 if folder.folder_type != 'custom': 

351 from core.exceptions import HttpError 

352 raise HttpError(400, "Cannot delete system folders") 

353 

354 folder.delete() 

355 return {'success': True, 'message': 'Folder deleted'} 

356 

357 

358# ============= Email Filters/Rules ============= 

359 

360@router.get('/filters', response=List[EmailFilterOut], auth=JWTAuth()) 

361def list_email_filters(request, account_id: UUID): 

362 """List all filters for an email account.""" 

363 tenant_id = require_tenant(request) 

364 account = get_object_or_404( 

365 EmailAccount, 

366 id=account_id, 

367 tenant_id=tenant_id 

368 ) 

369 

370 filters = EmailFilter.objects.filter( 

371 tenant_id=tenant_id, 

372 account=account 

373 ).order_by('priority', 'name') 

374 

375 return [ 

376 { 

377 'id': f.id, 

378 'name': f.name, 

379 'conditions': f.conditions, 

380 'action_type': f.action_type, 

381 'action_folder_id': f.action_folder_id, 

382 'is_active': f.is_active, 

383 'priority': f.priority, 

384 } 

385 for f in filters 

386 ] 

387 

388 

389@router.post('/filters', response=EmailFilterOut, auth=JWTAuth()) 

390def create_email_filter(request, data: EmailFilterCreateIn, account_id: UUID): 

391 """Create a new email filter/rule.""" 

392 tenant_id = require_tenant(request) 

393 account = get_object_or_404( 

394 EmailAccount, 

395 id=account_id, 

396 tenant_id=tenant_id 

397 ) 

398 

399 # Validate action_folder if provided 

400 action_folder = None 

401 if data.action_folder_id: 

402 action_folder = get_object_or_404( 

403 EmailFolder, 

404 id=data.action_folder_id, 

405 account=account, 

406 tenant_id=tenant_id 

407 ) 

408 

409 email_filter = EmailFilter.objects.create( 

410 tenant_id=tenant_id, 

411 account=account, 

412 name=data.name, 

413 conditions={'conditions': [c.model_dump() for c in data.conditions]}, 

414 action_type=data.action_type, 

415 action_folder=action_folder, 

416 is_active=data.is_active, 

417 priority=data.priority, 

418 ) 

419 

420 return { 

421 'id': email_filter.id, 

422 'name': email_filter.name, 

423 'conditions': email_filter.conditions, 

424 'action_type': email_filter.action_type, 

425 'action_folder_id': email_filter.action_folder_id, 

426 'is_active': email_filter.is_active, 

427 'priority': email_filter.priority, 

428 } 

429 

430 

431@router.put('/filters/{filter_id}', response=EmailFilterOut, auth=JWTAuth()) 

432def update_email_filter(request, filter_id: UUID, data: EmailFilterCreateIn, account_id: UUID): 

433 """Update an email filter.""" 

434 tenant_id = require_tenant(request) 

435 account = get_object_or_404( 

436 EmailAccount, 

437 id=account_id, 

438 tenant_id=tenant_id 

439 ) 

440 

441 email_filter = get_object_or_404( 

442 EmailFilter, 

443 id=filter_id, 

444 account=account, 

445 tenant_id=tenant_id 

446 ) 

447 

448 # Validate action_folder if provided 

449 action_folder = None 

450 if data.action_folder_id: 

451 action_folder = get_object_or_404( 

452 EmailFolder, 

453 id=data.action_folder_id, 

454 account=account, 

455 tenant_id=tenant_id 

456 ) 

457 

458 email_filter.name = data.name 

459 email_filter.conditions = {'conditions': [c.model_dump() for c in data.conditions]} 

460 email_filter.action_type = data.action_type 

461 email_filter.action_folder = action_folder 

462 email_filter.is_active = data.is_active 

463 email_filter.priority = data.priority 

464 email_filter.save() 

465 

466 return { 

467 'id': email_filter.id, 

468 'name': email_filter.name, 

469 'conditions': email_filter.conditions, 

470 'action_type': email_filter.action_type, 

471 'action_folder_id': email_filter.action_folder_id, 

472 'is_active': email_filter.is_active, 

473 'priority': email_filter.priority, 

474 } 

475 

476 

477@router.delete('/filters/{filter_id}', auth=JWTAuth()) 

478def delete_email_filter(request, filter_id: UUID, account_id: UUID): 

479 """Delete an email filter.""" 

480 tenant_id = require_tenant(request) 

481 account = get_object_or_404( 

482 EmailAccount, 

483 id=account_id, 

484 tenant_id=tenant_id 

485 ) 

486 

487 email_filter = get_object_or_404( 

488 EmailFilter, 

489 id=filter_id, 

490 account=account, 

491 tenant_id=tenant_id 

492 ) 

493 

494 email_filter.delete() 

495 return {'success': True, 'message': 'Filter deleted'} 

496 

497 

498# ============= Email Messages ============= 

499 

500@router.get('/messages', response=List[EmailMessageOut], auth=JWTAuth()) 

501def list_email_messages( 

502 request, 

503 account_id: UUID = Query(...), 

504 folder: str = Query(default='INBOX'), 

505 page: int = Query(default=1), 

506 per_page: int = Query(default=50) 

507): 

508 """List messages in a folder.""" 

509 tenant_id = require_tenant(request) 

510 account = get_object_or_404( 

511 EmailAccount, 

512 id=account_id, 

513 tenant_id=tenant_id 

514 ) 

515 

516 # Fetch from server 

517 messages = EmailMessageService.fetch_messages( 

518 account=account, 

519 folder_name=folder, 

520 page=page, 

521 per_page=per_page 

522 ) 

523 

524 # Handle both EmailMessage objects and dicts 

525 return [ 

526 { 

527 'id': msg['id'] if isinstance(msg, dict) else str(msg.id), 

528 'message_id': msg['message_id'] if isinstance(msg, dict) else msg.message_id, 

529 'subject': msg['subject'] if isinstance(msg, dict) else msg.subject, 

530 'snippet': msg['snippet'] if isinstance(msg, dict) else msg.snippet, 

531 'from_address': msg['from_address'] if isinstance(msg, dict) else msg.from_address, 

532 'from_name': msg['from_name'] if isinstance(msg, dict) else msg.from_name, 

533 'to_addresses': msg['to_addresses'] if isinstance(msg, dict) else (msg.to_addresses or []), 

534 'cc_addresses': msg['cc_addresses'] if isinstance(msg, dict) else msg.cc_addresses, 

535 'is_read': msg['is_read'] if isinstance(msg, dict) else msg.is_read, 

536 'is_starred': msg['is_starred'] if isinstance(msg, dict) else msg.is_starred, 

537 'is_draft': msg['is_draft'] if isinstance(msg, dict) else msg.is_draft, 

538 'is_sent': msg['is_sent'] if isinstance(msg, dict) else msg.is_sent, 

539 'sent_at': msg['sent_at'] if isinstance(msg, dict) else msg.sent_at, 

540 'received_at': msg['received_at'] if isinstance(msg, dict) else msg.received_at, 

541 'folder_id': msg['folder_id'] if isinstance(msg, dict) else str(msg.folder_id), 

542 'account_id': msg['account_id'] if isinstance(msg, dict) else str(msg.account_id), 

543 } 

544 for msg in messages 

545 ] 

546 

547 

548@router.get('/messages/{message_id}', response=EmailMessageDetailOut, auth=JWTAuth()) 

549def get_email_message(request, message_id: UUID, account_id: UUID = Query(...)): 

550 """Get message details with body.""" 

551 tenant_id = require_tenant(request) 

552 account = get_object_or_404( 

553 EmailAccount, 

554 id=account_id, 

555 tenant_id=tenant_id 

556 ) 

557 

558 msg = EmailMessageService.get_message_detail(account, message_id) 

559 

560 if not msg: 

561 return {'error': 'Message not found'} 

562 

563 return { 

564 'id': msg.id, 

565 'message_id': msg.message_id, 

566 'subject': msg.subject, 

567 'snippet': msg.snippet, 

568 'from_address': msg.from_address, 

569 'from_name': msg.from_name, 

570 'to_addresses': msg.to_addresses or [], 

571 'cc_addresses': msg.cc_addresses, 

572 'is_read': msg.is_read, 

573 'is_starred': msg.is_starred, 

574 'is_draft': msg.is_draft, 

575 'is_sent': msg.is_sent, 

576 'sent_at': msg.sent_at, 

577 'received_at': msg.received_at, 

578 'folder_id': msg.folder_id, 

579 'account_id': msg.account_id, 

580 'body_text': msg.body_text, 

581 'body_html': msg.body_html, 

582 'attachments': [ 

583 { 

584 'id': att.id, 

585 'file_name': att.file_name, 

586 'content_type': att.content_type, 

587 'size_bytes': att.size_bytes, 

588 'file_path': att.file_path, 

589 } 

590 for att in msg.attachments.all() 

591 ], 

592 } 

593 

594 

595@router.patch('/messages/{message_id}', response=EmailMessageOut, auth=JWTAuth()) 

596def update_email_message(request, message_id: UUID, data: EmailMessageUpdateIn): 

597 """Update message (read, starred, flagged).""" 

598 tenant_id = require_tenant(request) 

599 msg = get_object_or_404( 

600 EmailMessage, 

601 id=message_id, 

602 tenant_id=tenant_id 

603 ) 

604 

605 if data.is_read is not None: 

606 if data.is_read: 

607 EmailMessageService.mark_as_read(msg) 

608 else: 

609 EmailMessageService.mark_as_unread(msg) 

610 

611 if data.is_starred is not None: 

612 msg.is_starred = data.is_starred 

613 msg.save() 

614 

615 if data.is_flagged is not None: 

616 msg.is_flagged = data.is_flagged 

617 msg.save() 

618 

619 return { 

620 'id': msg.id, 

621 'message_id': msg.message_id, 

622 'subject': msg.subject, 

623 'snippet': msg.snippet, 

624 'from_address': msg.from_address, 

625 'from_name': msg.from_name, 

626 'to_addresses': msg.to_addresses or [], 

627 'cc_addresses': msg.cc_addresses, 

628 'is_read': msg.is_read, 

629 'is_starred': msg.is_starred, 

630 'is_draft': msg.is_draft, 

631 'is_sent': msg.is_sent, 

632 'sent_at': msg.sent_at, 

633 'received_at': msg.received_at, 

634 'folder_id': msg.folder_id, 

635 'account_id': msg.account_id, 

636 } 

637 

638 

639@router.delete('/messages/{message_id}', auth=JWTAuth()) 

640def delete_email_message(request, message_id: UUID): 

641 """Delete a message.""" 

642 tenant_id = require_tenant(request) 

643 msg = get_object_or_404( 

644 EmailMessage, 

645 id=message_id, 

646 tenant_id=tenant_id 

647 ) 

648 

649 EmailMessageService.delete_message(msg) 

650 return {'success': True} 

651 

652 

653@router.post('/send', auth=JWTAuth()) 

654def send_email(request, data: EmailSendIn, account_id: UUID): 

655 """Send an email.""" 

656 tenant_id = require_tenant(request) 

657 account = get_object_or_404( 

658 EmailAccount, 

659 id=account_id, 

660 tenant_id=tenant_id 

661 ) 

662 

663 to_addresses = [{'email': e} for e in data.to] 

664 cc_addresses = [{'email': e} for e in data.cc] if data.cc else None 

665 bcc_addresses = [{'email': e} for e in data.bcc] if data.bcc else None 

666 

667 success, message = EmailMessageService.send_email( 

668 account=account, 

669 to_addresses=to_addresses, 

670 subject=data.subject, 

671 body=data.body, 

672 cc=cc_addresses, 

673 bcc=bcc_addresses, 

674 is_html=data.is_html 

675 ) 

676 

677 if success: 

678 return {'success': True, 'message': message} 

679 else: 

680 return {'success': False, 'error': message} 

681 

682 

683@router.post('/drafts', auth=JWTAuth()) 

684def save_draft(request, data: EmailDraftIn, account_id: UUID): 

685 """Save a draft.""" 

686 tenant_id = require_tenant(request) 

687 account = get_object_or_404( 

688 EmailAccount, 

689 id=account_id, 

690 tenant_id=tenant_id 

691 ) 

692 

693 # Find draft folder 

694 draft_folder = EmailFolder.objects.filter( 

695 tenant_id=tenant_id, 

696 account=account, 

697 folder_type='draft' 

698 ).first() 

699 

700 # Create or update draft 

701 draft, _ = EmailMessage.objects.update_or_create( 

702 tenant_id=tenant_id, 

703 account=account, 

704 folder=draft_folder, 

705 message_id=f"<draft-{request.user.id}-{timezone.now().timestamp()}@local>", 

706 defaults={ 

707 'from_address': account.email_address, 

708 'from_name': account.display_name, 

709 'to_addresses': [{'email': e} for e in data.to] if data.to else [], 

710 'cc_addresses': [{'email': e} for e in data.cc] if data.cc else None, 

711 'bcc_addresses': [{'email': e} for e in data.bcc] if data.bcc else None, 

712 'subject': data.subject, 

713 'body_text': data.body if not data.body.startswith('<') else None, 

714 'body_html': data.body if data.body.startswith('<') else None, 

715 'is_draft': True, 

716 } 

717 ) 

718 

719 return {'id': str(draft.id), 'success': True} 

720 

721 

722# ============= Internal Email ============= 

723 

724@router.get('/internal', response=List[InternalEmailOut], auth=JWTAuth()) 

725def list_internal_emails(request, page: int = 1, per_page: int = 50): 

726 """List internal emails for the current user.""" 

727 tenant_id = require_tenant(request) 

728 user_id = request.user.id 

729 

730 # Get emails where user is recipient or sender 

731 received = InternalEmailRecipient.objects.filter( 

732 tenant_id=tenant_id, 

733 recipient_id=user_id 

734 ).select_related('email', 'email__sender').order_by('-email__sent_at') 

735 

736 start = (page - 1) * per_page 

737 end = start + per_page 

738 

739 result = [] 

740 for recipient in received[start:end]: 

741 email = recipient.email 

742 result.append({ 

743 'id': email.id, 

744 'subject': email.subject, 

745 'body': email.body, 

746 'sender_name': email.sender.display_name or email.sender.email, 

747 'sender_email': email.sender.email, 

748 'recipients': [ 

749 {'email': r.recipient.email, 'name': r.recipient.display_name or None} 

750 for r in email.recipients.all() 

751 ], 

752 'sent_at': email.sent_at, 

753 'is_read': recipient.is_read, 

754 }) 

755 

756 return result 

757 

758 

759@router.get('/internal/count', response=InternalEmailCountOut, auth=JWTAuth()) 

760def get_internal_email_count(request): 

761 """Get internal email unread count.""" 

762 tenant_id = require_tenant(request) 

763 user_id = request.user.id 

764 

765 total = InternalEmailRecipient.objects.filter( 

766 tenant_id=tenant_id, 

767 recipient_id=user_id 

768 ).count() 

769 

770 unread = InternalEmailRecipient.objects.filter( 

771 tenant_id=tenant_id, 

772 recipient_id=user_id, 

773 is_read=False 

774 ).count() 

775 

776 return {'unread_count': unread, 'total_count': total} 

777 

778 

779@router.post('/internal', auth=JWTAuth()) 

780def send_internal_email(request, data: InternalEmailSendIn): 

781 """Send an internal email to other ERP users.""" 

782 tenant_id = require_tenant(request) 

783 user_id = request.user.id 

784 

785 # Create internal email 

786 internal = InternalEmail.objects.create( 

787 tenant_id=tenant_id, 

788 sender_id=user_id, 

789 subject=data.subject, 

790 body=data.body, 

791 ) 

792 

793 # Add recipients 

794 for recipient_id in data.to: 

795 InternalEmailRecipient.objects.create( 

796 tenant_id=tenant_id, 

797 email=internal, 

798 recipient_id=recipient_id, 

799 ) 

800 

801 return {'id': str(internal.id), 'success': True} 

802 

803 

804@router.patch('/internal/{email_id}/read', auth=JWTAuth()) 

805def mark_internal_email_read(request, email_id: UUID): 

806 """Mark internal email as read.""" 

807 tenant_id = require_tenant(request) 

808 user_id = request.user.id 

809 

810 recipient = InternalEmailRecipient.objects.filter( 

811 tenant_id=tenant_id, 

812 email_id=email_id, 

813 recipient_id=user_id 

814 ).first() 

815 

816 if recipient: 

817 recipient.is_read = True 

818 recipient.read_at = timezone.now() 

819 recipient.save() 

820 

821 return {'success': True}