mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-22 14:06:42 +02:00
chore: archive finance, mail, moodlit apps and rename voxel-lava
- Move finance, mail, moodlit to apps-archived for later development - Rename games/voxel-lava to games/voxelava 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
c3c272abc9
commit
ace7fa8f7f
427 changed files with 0 additions and 0 deletions
|
|
@ -0,0 +1,113 @@
|
|||
import { type EmailAccount, type Email, type Folder } from '../../db/schema';
|
||||
|
||||
export interface SyncState {
|
||||
lastSyncAt?: Date;
|
||||
lastMessageId?: string;
|
||||
historyId?: string; // Gmail specific
|
||||
deltaLink?: string; // Outlook specific
|
||||
uidValidity?: number; // IMAP specific
|
||||
highestModSeq?: string; // IMAP specific
|
||||
}
|
||||
|
||||
export interface SyncResult {
|
||||
success: boolean;
|
||||
newEmails: number;
|
||||
updatedEmails: number;
|
||||
deletedEmails: number;
|
||||
newFolders: number;
|
||||
error?: string;
|
||||
newSyncState: SyncState;
|
||||
}
|
||||
|
||||
export interface FetchedEmail {
|
||||
messageId: string;
|
||||
externalId?: string;
|
||||
subject?: string;
|
||||
fromAddress?: string;
|
||||
fromName?: string;
|
||||
toAddresses: { email: string; name?: string }[];
|
||||
ccAddresses?: { email: string; name?: string }[];
|
||||
bccAddresses?: { email: string; name?: string }[];
|
||||
snippet?: string;
|
||||
bodyPlain?: string;
|
||||
bodyHtml?: string;
|
||||
sentAt?: Date;
|
||||
receivedAt?: Date;
|
||||
isRead: boolean;
|
||||
isStarred: boolean;
|
||||
hasAttachments: boolean;
|
||||
threadId?: string;
|
||||
inReplyTo?: string;
|
||||
references?: string[];
|
||||
headers?: Record<string, string>;
|
||||
attachments?: {
|
||||
filename: string;
|
||||
mimeType: string;
|
||||
size: number;
|
||||
contentId?: string;
|
||||
content?: Buffer;
|
||||
}[];
|
||||
}
|
||||
|
||||
export interface FetchedFolder {
|
||||
name: string;
|
||||
path: string;
|
||||
type: 'inbox' | 'sent' | 'drafts' | 'trash' | 'spam' | 'archive' | 'custom';
|
||||
delimiter?: string;
|
||||
flags?: string[];
|
||||
}
|
||||
|
||||
export interface EmailProvider {
|
||||
/**
|
||||
* Connect to the email provider
|
||||
*/
|
||||
connect(account: EmailAccount, password?: string): Promise<void>;
|
||||
|
||||
/**
|
||||
* Disconnect from the email provider
|
||||
*/
|
||||
disconnect(): Promise<void>;
|
||||
|
||||
/**
|
||||
* Sync folders from the provider
|
||||
*/
|
||||
syncFolders(account: EmailAccount): Promise<FetchedFolder[]>;
|
||||
|
||||
/**
|
||||
* Perform delta sync to get new/updated/deleted emails
|
||||
*/
|
||||
sync(account: EmailAccount, state: SyncState): Promise<SyncResult>;
|
||||
|
||||
/**
|
||||
* Fetch a single email by ID
|
||||
*/
|
||||
fetchEmail(account: EmailAccount, externalId: string): Promise<FetchedEmail | null>;
|
||||
|
||||
/**
|
||||
* Fetch emails from a folder
|
||||
*/
|
||||
fetchEmails(
|
||||
account: EmailAccount,
|
||||
folderPath: string,
|
||||
options?: { limit?: number; since?: Date }
|
||||
): Promise<FetchedEmail[]>;
|
||||
|
||||
/**
|
||||
* Update email flags (read, starred)
|
||||
*/
|
||||
updateFlags(
|
||||
account: EmailAccount,
|
||||
externalId: string,
|
||||
flags: { isRead?: boolean; isStarred?: boolean }
|
||||
): Promise<void>;
|
||||
|
||||
/**
|
||||
* Move email to a different folder
|
||||
*/
|
||||
moveEmail(account: EmailAccount, externalId: string, targetFolderPath: string): Promise<void>;
|
||||
|
||||
/**
|
||||
* Delete an email
|
||||
*/
|
||||
deleteEmail(account: EmailAccount, externalId: string): Promise<void>;
|
||||
}
|
||||
|
|
@ -0,0 +1,307 @@
|
|||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { google, gmail_v1 } from 'googleapis';
|
||||
import {
|
||||
type EmailProvider,
|
||||
type SyncState,
|
||||
type SyncResult,
|
||||
type FetchedEmail,
|
||||
type FetchedFolder,
|
||||
} from '../interfaces/email-provider.interface';
|
||||
import { type EmailAccount } from '../../db/schema';
|
||||
|
||||
@Injectable()
|
||||
export class GmailProvider implements EmailProvider {
|
||||
private readonly logger = new Logger(GmailProvider.name);
|
||||
private gmail: gmail_v1.Gmail | null = null;
|
||||
|
||||
async connect(account: EmailAccount): Promise<void> {
|
||||
if (!account.accessToken) {
|
||||
throw new Error('Gmail access token not configured');
|
||||
}
|
||||
|
||||
const auth = new google.auth.OAuth2();
|
||||
auth.setCredentials({ access_token: account.accessToken });
|
||||
|
||||
this.gmail = google.gmail({ version: 'v1', auth });
|
||||
}
|
||||
|
||||
async disconnect(): Promise<void> {
|
||||
this.gmail = null;
|
||||
}
|
||||
|
||||
async syncFolders(account: EmailAccount): Promise<FetchedFolder[]> {
|
||||
if (!this.gmail) throw new Error('Not connected');
|
||||
|
||||
const folders: FetchedFolder[] = [];
|
||||
const response = await this.gmail.users.labels.list({ userId: 'me' });
|
||||
|
||||
for (const label of response.data.labels || []) {
|
||||
folders.push({
|
||||
name: label.name || '',
|
||||
path: label.id || '',
|
||||
type: this.mapLabelType(label.id || ''),
|
||||
});
|
||||
}
|
||||
|
||||
return folders;
|
||||
}
|
||||
|
||||
async sync(account: EmailAccount, state: SyncState): Promise<SyncResult> {
|
||||
if (!this.gmail) throw new Error('Not connected');
|
||||
|
||||
const result: SyncResult = {
|
||||
success: true,
|
||||
newEmails: 0,
|
||||
updatedEmails: 0,
|
||||
deletedEmails: 0,
|
||||
newFolders: 0,
|
||||
newSyncState: { ...state },
|
||||
};
|
||||
|
||||
try {
|
||||
// Use Gmail History API for incremental sync
|
||||
if (state.historyId) {
|
||||
const historyResponse = await this.gmail.users.history.list({
|
||||
userId: 'me',
|
||||
startHistoryId: state.historyId,
|
||||
});
|
||||
|
||||
const history = historyResponse.data.history || [];
|
||||
for (const record of history) {
|
||||
result.newEmails += record.messagesAdded?.length || 0;
|
||||
result.deletedEmails += record.messagesDeleted?.length || 0;
|
||||
}
|
||||
|
||||
result.newSyncState.historyId = historyResponse.data.historyId || state.historyId;
|
||||
} else {
|
||||
// Initial sync - fetch recent messages
|
||||
const messagesResponse = await this.gmail.users.messages.list({
|
||||
userId: 'me',
|
||||
maxResults: 100,
|
||||
q: 'in:inbox',
|
||||
});
|
||||
|
||||
result.newEmails = messagesResponse.data.messages?.length || 0;
|
||||
|
||||
// Get current history ID for future syncs
|
||||
const profile = await this.gmail.users.getProfile({ userId: 'me' });
|
||||
result.newSyncState.historyId = profile.data.historyId || undefined;
|
||||
}
|
||||
|
||||
result.newSyncState.lastSyncAt = new Date();
|
||||
} catch (error) {
|
||||
result.success = false;
|
||||
result.error = error instanceof Error ? error.message : 'Sync failed';
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
async fetchEmail(account: EmailAccount, externalId: string): Promise<FetchedEmail | null> {
|
||||
if (!this.gmail) throw new Error('Not connected');
|
||||
|
||||
try {
|
||||
const response = await this.gmail.users.messages.get({
|
||||
userId: 'me',
|
||||
id: externalId,
|
||||
format: 'full',
|
||||
});
|
||||
|
||||
return this.parseGmailMessage(response.data);
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to fetch email ${externalId}:`, error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async fetchEmails(
|
||||
account: EmailAccount,
|
||||
folderPath: string,
|
||||
options?: { limit?: number; since?: Date }
|
||||
): Promise<FetchedEmail[]> {
|
||||
if (!this.gmail) throw new Error('Not connected');
|
||||
|
||||
const emails: FetchedEmail[] = [];
|
||||
const limit = options?.limit || 50;
|
||||
|
||||
// Build query
|
||||
let query = `in:${folderPath === 'INBOX' ? 'inbox' : folderPath}`;
|
||||
if (options?.since) {
|
||||
const dateStr = options.since.toISOString().split('T')[0];
|
||||
query += ` after:${dateStr}`;
|
||||
}
|
||||
|
||||
const listResponse = await this.gmail.users.messages.list({
|
||||
userId: 'me',
|
||||
maxResults: limit,
|
||||
q: query,
|
||||
});
|
||||
|
||||
for (const message of listResponse.data.messages || []) {
|
||||
if (message.id) {
|
||||
const email = await this.fetchEmail(account, message.id);
|
||||
if (email) emails.push(email);
|
||||
}
|
||||
}
|
||||
|
||||
return emails;
|
||||
}
|
||||
|
||||
async updateFlags(
|
||||
account: EmailAccount,
|
||||
externalId: string,
|
||||
flags: { isRead?: boolean; isStarred?: boolean }
|
||||
): Promise<void> {
|
||||
if (!this.gmail) throw new Error('Not connected');
|
||||
|
||||
const addLabels: string[] = [];
|
||||
const removeLabels: string[] = [];
|
||||
|
||||
if (flags.isRead !== undefined) {
|
||||
if (flags.isRead) {
|
||||
removeLabels.push('UNREAD');
|
||||
} else {
|
||||
addLabels.push('UNREAD');
|
||||
}
|
||||
}
|
||||
|
||||
if (flags.isStarred !== undefined) {
|
||||
if (flags.isStarred) {
|
||||
addLabels.push('STARRED');
|
||||
} else {
|
||||
removeLabels.push('STARRED');
|
||||
}
|
||||
}
|
||||
|
||||
if (addLabels.length > 0 || removeLabels.length > 0) {
|
||||
await this.gmail.users.messages.modify({
|
||||
userId: 'me',
|
||||
id: externalId,
|
||||
requestBody: {
|
||||
addLabelIds: addLabels.length > 0 ? addLabels : undefined,
|
||||
removeLabelIds: removeLabels.length > 0 ? removeLabels : undefined,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async moveEmail(
|
||||
account: EmailAccount,
|
||||
externalId: string,
|
||||
targetFolderPath: string
|
||||
): Promise<void> {
|
||||
if (!this.gmail) throw new Error('Not connected');
|
||||
|
||||
// In Gmail, moving is done by modifying labels
|
||||
const targetLabel = this.pathToLabelId(targetFolderPath);
|
||||
|
||||
await this.gmail.users.messages.modify({
|
||||
userId: 'me',
|
||||
id: externalId,
|
||||
requestBody: {
|
||||
addLabelIds: [targetLabel],
|
||||
removeLabelIds: ['INBOX'],
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async deleteEmail(account: EmailAccount, externalId: string): Promise<void> {
|
||||
if (!this.gmail) throw new Error('Not connected');
|
||||
|
||||
// Move to trash (or permanently delete)
|
||||
await this.gmail.users.messages.trash({
|
||||
userId: 'me',
|
||||
id: externalId,
|
||||
});
|
||||
}
|
||||
|
||||
private mapLabelType(labelId: string): FetchedFolder['type'] {
|
||||
const labelMap: Record<string, FetchedFolder['type']> = {
|
||||
INBOX: 'inbox',
|
||||
SENT: 'sent',
|
||||
DRAFT: 'drafts',
|
||||
TRASH: 'trash',
|
||||
SPAM: 'spam',
|
||||
};
|
||||
return labelMap[labelId] || 'custom';
|
||||
}
|
||||
|
||||
private pathToLabelId(path: string): string {
|
||||
const pathMap: Record<string, string> = {
|
||||
inbox: 'INBOX',
|
||||
sent: 'SENT',
|
||||
drafts: 'DRAFT',
|
||||
trash: 'TRASH',
|
||||
spam: 'SPAM',
|
||||
};
|
||||
return pathMap[path.toLowerCase()] || path;
|
||||
}
|
||||
|
||||
private parseGmailMessage(message: gmail_v1.Schema$Message): FetchedEmail {
|
||||
const headers = message.payload?.headers || [];
|
||||
const getHeader = (name: string) =>
|
||||
headers.find((h) => h.name?.toLowerCase() === name.toLowerCase())?.value;
|
||||
|
||||
const labels = message.labelIds || [];
|
||||
|
||||
// Extract body
|
||||
let bodyPlain = '';
|
||||
let bodyHtml = '';
|
||||
|
||||
const extractBody = (part: gmail_v1.Schema$MessagePart) => {
|
||||
if (part.mimeType === 'text/plain' && part.body?.data) {
|
||||
bodyPlain = Buffer.from(part.body.data, 'base64').toString('utf-8');
|
||||
}
|
||||
if (part.mimeType === 'text/html' && part.body?.data) {
|
||||
bodyHtml = Buffer.from(part.body.data, 'base64').toString('utf-8');
|
||||
}
|
||||
for (const subPart of part.parts || []) {
|
||||
extractBody(subPart);
|
||||
}
|
||||
};
|
||||
|
||||
if (message.payload) {
|
||||
extractBody(message.payload);
|
||||
}
|
||||
|
||||
// Parse from header
|
||||
const fromHeader = getHeader('From') || '';
|
||||
const fromMatch = fromHeader.match(/^(?:"?([^"<]*)"?\s*)?<?([^>]+)>?$/);
|
||||
const fromName = fromMatch?.[1]?.trim();
|
||||
const fromAddress = fromMatch?.[2] || fromHeader;
|
||||
|
||||
// Parse to/cc addresses
|
||||
const parseAddresses = (header: string | undefined): { email: string; name?: string }[] => {
|
||||
if (!header) return [];
|
||||
return header.split(',').map((addr) => {
|
||||
const match = addr.trim().match(/^(?:"?([^"<]*)"?\s*)?<?([^>]+)>?$/);
|
||||
return {
|
||||
email: match?.[2] || addr.trim(),
|
||||
name: match?.[1]?.trim(),
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
return {
|
||||
messageId: getHeader('Message-ID') || message.id || '',
|
||||
externalId: message.id ?? undefined,
|
||||
threadId: message.threadId ?? undefined,
|
||||
subject: getHeader('Subject') ?? undefined,
|
||||
fromAddress,
|
||||
fromName,
|
||||
toAddresses: parseAddresses(getHeader('To') ?? undefined),
|
||||
ccAddresses: parseAddresses(getHeader('Cc') ?? undefined),
|
||||
snippet: message.snippet ?? undefined,
|
||||
bodyPlain: bodyPlain ?? undefined,
|
||||
bodyHtml: bodyHtml ?? undefined,
|
||||
sentAt: getHeader('Date') ? new Date(getHeader('Date')!) : undefined,
|
||||
receivedAt: message.internalDate ? new Date(parseInt(message.internalDate, 10)) : undefined,
|
||||
isRead: !labels.includes('UNREAD'),
|
||||
isStarred: labels.includes('STARRED'),
|
||||
hasAttachments:
|
||||
message.payload?.parts?.some((p) => p.filename && p.filename.length > 0) || false,
|
||||
inReplyTo: getHeader('In-Reply-To') ?? undefined,
|
||||
references: getHeader('References')?.split(/\s+/),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,312 @@
|
|||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { ImapFlow, type MailboxObject, type FetchMessageObject, type ListResponse } from 'imapflow';
|
||||
import { simpleParser, type ParsedMail, type AddressObject, type Attachment } from 'mailparser';
|
||||
import {
|
||||
type EmailProvider,
|
||||
type SyncState,
|
||||
type SyncResult,
|
||||
type FetchedEmail,
|
||||
type FetchedFolder,
|
||||
} from '../interfaces/email-provider.interface';
|
||||
import { type EmailAccount } from '../../db/schema';
|
||||
|
||||
@Injectable()
|
||||
export class ImapProvider implements EmailProvider {
|
||||
private readonly logger = new Logger(ImapProvider.name);
|
||||
private client: ImapFlow | null = null;
|
||||
|
||||
async connect(account: EmailAccount, password?: string): Promise<void> {
|
||||
if (!account.imapHost || !account.imapPort) {
|
||||
throw new Error('IMAP settings not configured');
|
||||
}
|
||||
|
||||
this.client = new ImapFlow({
|
||||
host: account.imapHost,
|
||||
port: account.imapPort,
|
||||
secure: account.imapSecurity === 'ssl',
|
||||
auth: {
|
||||
user: account.email,
|
||||
pass: password || '',
|
||||
},
|
||||
logger: false,
|
||||
});
|
||||
|
||||
await this.client.connect();
|
||||
}
|
||||
|
||||
async disconnect(): Promise<void> {
|
||||
if (this.client) {
|
||||
await this.client.logout();
|
||||
this.client = null;
|
||||
}
|
||||
}
|
||||
|
||||
async syncFolders(account: EmailAccount): Promise<FetchedFolder[]> {
|
||||
if (!this.client) throw new Error('Not connected');
|
||||
|
||||
const folders: FetchedFolder[] = [];
|
||||
const mailboxes = await this.client.list();
|
||||
|
||||
for (const mailbox of mailboxes) {
|
||||
folders.push({
|
||||
name: mailbox.name,
|
||||
path: mailbox.path,
|
||||
type: this.mapFolderType(mailbox),
|
||||
delimiter: mailbox.delimiter,
|
||||
flags: mailbox.flags ? Array.from(mailbox.flags) : [],
|
||||
});
|
||||
}
|
||||
|
||||
return folders;
|
||||
}
|
||||
|
||||
async sync(account: EmailAccount, state: SyncState): Promise<SyncResult> {
|
||||
if (!this.client) throw new Error('Not connected');
|
||||
|
||||
const result: SyncResult = {
|
||||
success: true,
|
||||
newEmails: 0,
|
||||
updatedEmails: 0,
|
||||
deletedEmails: 0,
|
||||
newFolders: 0,
|
||||
newSyncState: { ...state },
|
||||
};
|
||||
|
||||
try {
|
||||
// Open INBOX
|
||||
const mailbox = await this.client.mailboxOpen('INBOX');
|
||||
|
||||
// Use UIDVALIDITY and HIGHESTMODSEQ for incremental sync
|
||||
const currentUidValidity = Number(mailbox.uidValidity);
|
||||
const currentModSeq = mailbox.highestModseq?.toString();
|
||||
|
||||
// If UIDVALIDITY changed, we need full resync
|
||||
if (state.uidValidity && state.uidValidity !== currentUidValidity) {
|
||||
this.logger.warn('UIDVALIDITY changed, full resync required');
|
||||
// Full resync would be handled separately
|
||||
}
|
||||
|
||||
// Fetch new messages since last sync
|
||||
const since = state.lastSyncAt || new Date(Date.now() - 30 * 24 * 60 * 60 * 1000); // Default: 30 days
|
||||
|
||||
const messages = await this.fetchEmailsInternal('INBOX', { since, limit: 100 });
|
||||
result.newEmails = messages.length;
|
||||
|
||||
result.newSyncState = {
|
||||
lastSyncAt: new Date(),
|
||||
uidValidity: currentUidValidity,
|
||||
highestModSeq: currentModSeq,
|
||||
};
|
||||
} catch (error) {
|
||||
result.success = false;
|
||||
result.error = error instanceof Error ? error.message : 'Sync failed';
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
async fetchEmail(account: EmailAccount, externalId: string): Promise<FetchedEmail | null> {
|
||||
if (!this.client) throw new Error('Not connected');
|
||||
|
||||
try {
|
||||
const mailbox = await this.client.mailboxOpen('INBOX');
|
||||
const uid = parseInt(externalId, 10);
|
||||
|
||||
for await (const message of this.client.fetch(uid, { source: true }, { uid: true })) {
|
||||
if (!message.source) {
|
||||
this.logger.warn(`Email ${externalId} has no source`);
|
||||
continue;
|
||||
}
|
||||
const parsed = await simpleParser(message.source);
|
||||
return this.parseEmail(message, parsed);
|
||||
}
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to fetch email ${externalId}:`, error);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
async fetchEmails(
|
||||
account: EmailAccount,
|
||||
folderPath: string,
|
||||
options?: { limit?: number; since?: Date }
|
||||
): Promise<FetchedEmail[]> {
|
||||
if (!this.client) throw new Error('Not connected');
|
||||
|
||||
return this.fetchEmailsInternal(folderPath, options);
|
||||
}
|
||||
|
||||
private async fetchEmailsInternal(
|
||||
folderPath: string,
|
||||
options?: { limit?: number; since?: Date }
|
||||
): Promise<FetchedEmail[]> {
|
||||
if (!this.client) throw new Error('Not connected');
|
||||
|
||||
const emails: FetchedEmail[] = [];
|
||||
const limit = options?.limit || 50;
|
||||
|
||||
await this.client.mailboxOpen(folderPath);
|
||||
|
||||
// Build search criteria
|
||||
const searchCriteria: any = {};
|
||||
if (options?.since) {
|
||||
searchCriteria.since = options.since;
|
||||
}
|
||||
|
||||
const searchResults = await this.client.search(searchCriteria, { uid: true });
|
||||
if (!searchResults || searchResults.length === 0) return emails;
|
||||
|
||||
const uidsToFetch = searchResults.slice(-limit); // Get most recent
|
||||
|
||||
for await (const message of this.client.fetch(
|
||||
uidsToFetch,
|
||||
{ source: true, flags: true },
|
||||
{ uid: true }
|
||||
)) {
|
||||
try {
|
||||
if (!message.source) {
|
||||
this.logger.warn(`Email UID ${message.uid} has no source`);
|
||||
continue;
|
||||
}
|
||||
const parsed = await simpleParser(message.source);
|
||||
const email = this.parseEmail(message, parsed);
|
||||
emails.push(email);
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to parse email UID ${message.uid}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
return emails;
|
||||
}
|
||||
|
||||
async updateFlags(
|
||||
account: EmailAccount,
|
||||
externalId: string,
|
||||
flags: { isRead?: boolean; isStarred?: boolean }
|
||||
): Promise<void> {
|
||||
if (!this.client) throw new Error('Not connected');
|
||||
|
||||
const uid = parseInt(externalId, 10);
|
||||
await this.client.mailboxOpen('INBOX');
|
||||
|
||||
if (flags.isRead !== undefined) {
|
||||
if (flags.isRead) {
|
||||
await this.client.messageFlagsAdd(uid, ['\\Seen'], { uid: true });
|
||||
} else {
|
||||
await this.client.messageFlagsRemove(uid, ['\\Seen'], { uid: true });
|
||||
}
|
||||
}
|
||||
|
||||
if (flags.isStarred !== undefined) {
|
||||
if (flags.isStarred) {
|
||||
await this.client.messageFlagsAdd(uid, ['\\Flagged'], { uid: true });
|
||||
} else {
|
||||
await this.client.messageFlagsRemove(uid, ['\\Flagged'], { uid: true });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async moveEmail(
|
||||
account: EmailAccount,
|
||||
externalId: string,
|
||||
targetFolderPath: string
|
||||
): Promise<void> {
|
||||
if (!this.client) throw new Error('Not connected');
|
||||
|
||||
const uid = parseInt(externalId, 10);
|
||||
await this.client.mailboxOpen('INBOX');
|
||||
await this.client.messageMove(uid, targetFolderPath, { uid: true });
|
||||
}
|
||||
|
||||
async deleteEmail(account: EmailAccount, externalId: string): Promise<void> {
|
||||
if (!this.client) throw new Error('Not connected');
|
||||
|
||||
const uid = parseInt(externalId, 10);
|
||||
await this.client.mailboxOpen('INBOX');
|
||||
await this.client.messageDelete(uid, { uid: true });
|
||||
}
|
||||
|
||||
private mapFolderType(mailbox: ListResponse): FetchedFolder['type'] {
|
||||
const specialUse = mailbox.specialUse;
|
||||
const nameLower = mailbox.path.toLowerCase();
|
||||
|
||||
if (specialUse === '\\Inbox' || nameLower === 'inbox') return 'inbox';
|
||||
if (specialUse === '\\Sent' || nameLower.includes('sent')) return 'sent';
|
||||
if (specialUse === '\\Drafts' || nameLower.includes('draft')) return 'drafts';
|
||||
if (specialUse === '\\Trash' || nameLower.includes('trash') || nameLower.includes('deleted'))
|
||||
return 'trash';
|
||||
if (specialUse === '\\Junk' || nameLower.includes('spam') || nameLower.includes('junk'))
|
||||
return 'spam';
|
||||
if (specialUse === '\\Archive' || nameLower.includes('archive')) return 'archive';
|
||||
|
||||
return 'custom';
|
||||
}
|
||||
|
||||
private parseEmail(message: FetchMessageObject, parsed: ParsedMail): FetchedEmail {
|
||||
const flags = message.flags || new Set();
|
||||
|
||||
return {
|
||||
messageId: parsed.messageId || `${message.uid}`,
|
||||
externalId: message.uid?.toString(),
|
||||
subject: parsed.subject,
|
||||
fromAddress: this.extractEmail(parsed.from),
|
||||
fromName: this.extractName(parsed.from),
|
||||
toAddresses: this.extractAddresses(parsed.to),
|
||||
ccAddresses: this.extractAddresses(parsed.cc),
|
||||
snippet: parsed.text?.substring(0, 200),
|
||||
bodyPlain: parsed.text,
|
||||
bodyHtml: parsed.html || undefined,
|
||||
sentAt: parsed.date,
|
||||
receivedAt: parsed.date,
|
||||
isRead: flags.has('\\Seen'),
|
||||
isStarred: flags.has('\\Flagged'),
|
||||
hasAttachments: (parsed.attachments?.length || 0) > 0,
|
||||
inReplyTo: parsed.inReplyTo,
|
||||
references: parsed.references
|
||||
? Array.isArray(parsed.references)
|
||||
? parsed.references
|
||||
: [parsed.references]
|
||||
: [],
|
||||
attachments: parsed.attachments?.map((att: Attachment) => ({
|
||||
filename: att.filename || 'attachment',
|
||||
mimeType: att.contentType,
|
||||
size: att.size,
|
||||
contentId: att.contentId,
|
||||
content: att.content,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
private extractEmail(address: AddressObject | undefined): string | undefined {
|
||||
if (!address?.value?.[0]) return undefined;
|
||||
return address.value[0].address;
|
||||
}
|
||||
|
||||
private extractName(address: AddressObject | undefined): string | undefined {
|
||||
if (!address?.value?.[0]) return undefined;
|
||||
return address.value[0].name;
|
||||
}
|
||||
|
||||
private extractAddresses(
|
||||
address: AddressObject | AddressObject[] | undefined
|
||||
): { email: string; name?: string }[] {
|
||||
if (!address) return [];
|
||||
|
||||
const addresses = Array.isArray(address) ? address : [address];
|
||||
const result: { email: string; name?: string }[] = [];
|
||||
|
||||
for (const addr of addresses) {
|
||||
for (const val of addr.value || []) {
|
||||
if (val.address) {
|
||||
result.push({
|
||||
email: val.address,
|
||||
name: val.name,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,242 @@
|
|||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { Client } from '@microsoft/microsoft-graph-client';
|
||||
import {
|
||||
type EmailProvider,
|
||||
type SyncState,
|
||||
type SyncResult,
|
||||
type FetchedEmail,
|
||||
type FetchedFolder,
|
||||
} from '../interfaces/email-provider.interface';
|
||||
import { type EmailAccount } from '../../db/schema';
|
||||
|
||||
interface GraphMessage {
|
||||
id: string;
|
||||
conversationId?: string;
|
||||
subject?: string;
|
||||
from?: { emailAddress: { address: string; name?: string } };
|
||||
toRecipients?: { emailAddress: { address: string; name?: string } }[];
|
||||
ccRecipients?: { emailAddress: { address: string; name?: string } }[];
|
||||
bodyPreview?: string;
|
||||
body?: { content: string; contentType: string };
|
||||
sentDateTime?: string;
|
||||
receivedDateTime?: string;
|
||||
isRead?: boolean;
|
||||
flag?: { flagStatus: string };
|
||||
hasAttachments?: boolean;
|
||||
internetMessageId?: string;
|
||||
parentFolderId?: string;
|
||||
}
|
||||
|
||||
interface GraphMailFolder {
|
||||
id: string;
|
||||
displayName: string;
|
||||
parentFolderId?: string;
|
||||
wellKnownName?: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class OutlookProvider implements EmailProvider {
|
||||
private readonly logger = new Logger(OutlookProvider.name);
|
||||
private client: Client | null = null;
|
||||
|
||||
async connect(account: EmailAccount): Promise<void> {
|
||||
if (!account.accessToken) {
|
||||
throw new Error('Outlook access token not configured');
|
||||
}
|
||||
|
||||
this.client = Client.init({
|
||||
authProvider: (done) => {
|
||||
done(null, account.accessToken!);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async disconnect(): Promise<void> {
|
||||
this.client = null;
|
||||
}
|
||||
|
||||
async syncFolders(account: EmailAccount): Promise<FetchedFolder[]> {
|
||||
if (!this.client) throw new Error('Not connected');
|
||||
|
||||
const folders: FetchedFolder[] = [];
|
||||
const response = await this.client.api('/me/mailFolders').get();
|
||||
|
||||
for (const folder of response.value as GraphMailFolder[]) {
|
||||
folders.push({
|
||||
name: folder.displayName,
|
||||
path: folder.id,
|
||||
type: this.mapFolderType(folder.wellKnownName),
|
||||
});
|
||||
}
|
||||
|
||||
return folders;
|
||||
}
|
||||
|
||||
async sync(account: EmailAccount, state: SyncState): Promise<SyncResult> {
|
||||
if (!this.client) throw new Error('Not connected');
|
||||
|
||||
const result: SyncResult = {
|
||||
success: true,
|
||||
newEmails: 0,
|
||||
updatedEmails: 0,
|
||||
deletedEmails: 0,
|
||||
newFolders: 0,
|
||||
newSyncState: { ...state },
|
||||
};
|
||||
|
||||
try {
|
||||
// Use delta query for incremental sync
|
||||
let deltaUrl = state.deltaLink || '/me/mailFolders/inbox/messages/delta';
|
||||
|
||||
const response = await this.client.api(deltaUrl).get();
|
||||
|
||||
result.newEmails = (response.value as GraphMessage[]).length;
|
||||
|
||||
// Save delta link for next sync
|
||||
if (response['@odata.deltaLink']) {
|
||||
result.newSyncState.deltaLink = response['@odata.deltaLink'];
|
||||
}
|
||||
|
||||
result.newSyncState.lastSyncAt = new Date();
|
||||
} catch (error) {
|
||||
result.success = false;
|
||||
result.error = error instanceof Error ? error.message : 'Sync failed';
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
async fetchEmail(account: EmailAccount, externalId: string): Promise<FetchedEmail | null> {
|
||||
if (!this.client) throw new Error('Not connected');
|
||||
|
||||
try {
|
||||
const response = await this.client
|
||||
.api(`/me/messages/${externalId}`)
|
||||
.select(
|
||||
'id,conversationId,subject,from,toRecipients,ccRecipients,bodyPreview,body,sentDateTime,receivedDateTime,isRead,flag,hasAttachments,internetMessageId'
|
||||
)
|
||||
.get();
|
||||
|
||||
return this.parseOutlookMessage(response);
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to fetch email ${externalId}:`, error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async fetchEmails(
|
||||
account: EmailAccount,
|
||||
folderPath: string,
|
||||
options?: { limit?: number; since?: Date }
|
||||
): Promise<FetchedEmail[]> {
|
||||
if (!this.client) throw new Error('Not connected');
|
||||
|
||||
const emails: FetchedEmail[] = [];
|
||||
const limit = options?.limit || 50;
|
||||
|
||||
let request = this.client
|
||||
.api(`/me/mailFolders/${folderPath}/messages`)
|
||||
.top(limit)
|
||||
.select(
|
||||
'id,conversationId,subject,from,toRecipients,ccRecipients,bodyPreview,body,sentDateTime,receivedDateTime,isRead,flag,hasAttachments,internetMessageId'
|
||||
);
|
||||
|
||||
if (options?.since) {
|
||||
request = request.filter(`receivedDateTime ge ${options.since.toISOString()}`);
|
||||
}
|
||||
|
||||
const response = await request.get();
|
||||
|
||||
for (const message of response.value as GraphMessage[]) {
|
||||
emails.push(this.parseOutlookMessage(message));
|
||||
}
|
||||
|
||||
return emails;
|
||||
}
|
||||
|
||||
async updateFlags(
|
||||
account: EmailAccount,
|
||||
externalId: string,
|
||||
flags: { isRead?: boolean; isStarred?: boolean }
|
||||
): Promise<void> {
|
||||
if (!this.client) throw new Error('Not connected');
|
||||
|
||||
const update: Record<string, any> = {};
|
||||
|
||||
if (flags.isRead !== undefined) {
|
||||
update.isRead = flags.isRead;
|
||||
}
|
||||
|
||||
if (flags.isStarred !== undefined) {
|
||||
update.flag = {
|
||||
flagStatus: flags.isStarred ? 'flagged' : 'notFlagged',
|
||||
};
|
||||
}
|
||||
|
||||
if (Object.keys(update).length > 0) {
|
||||
await this.client.api(`/me/messages/${externalId}`).patch(update);
|
||||
}
|
||||
}
|
||||
|
||||
async moveEmail(
|
||||
account: EmailAccount,
|
||||
externalId: string,
|
||||
targetFolderPath: string
|
||||
): Promise<void> {
|
||||
if (!this.client) throw new Error('Not connected');
|
||||
|
||||
await this.client.api(`/me/messages/${externalId}/move`).post({
|
||||
destinationId: targetFolderPath,
|
||||
});
|
||||
}
|
||||
|
||||
async deleteEmail(account: EmailAccount, externalId: string): Promise<void> {
|
||||
if (!this.client) throw new Error('Not connected');
|
||||
|
||||
// Move to deleted items
|
||||
await this.client.api(`/me/messages/${externalId}/move`).post({
|
||||
destinationId: 'deleteditems',
|
||||
});
|
||||
}
|
||||
|
||||
private mapFolderType(wellKnownName?: string): FetchedFolder['type'] {
|
||||
const folderMap: Record<string, FetchedFolder['type']> = {
|
||||
inbox: 'inbox',
|
||||
sentitems: 'sent',
|
||||
drafts: 'drafts',
|
||||
deleteditems: 'trash',
|
||||
junkemail: 'spam',
|
||||
archive: 'archive',
|
||||
};
|
||||
return folderMap[wellKnownName?.toLowerCase() || ''] || 'custom';
|
||||
}
|
||||
|
||||
private parseOutlookMessage(message: GraphMessage): FetchedEmail {
|
||||
return {
|
||||
messageId: message.internetMessageId || message.id,
|
||||
externalId: message.id,
|
||||
threadId: message.conversationId,
|
||||
subject: message.subject,
|
||||
fromAddress: message.from?.emailAddress.address,
|
||||
fromName: message.from?.emailAddress.name,
|
||||
toAddresses:
|
||||
message.toRecipients?.map((r) => ({
|
||||
email: r.emailAddress.address,
|
||||
name: r.emailAddress.name,
|
||||
})) || [],
|
||||
ccAddresses:
|
||||
message.ccRecipients?.map((r) => ({
|
||||
email: r.emailAddress.address,
|
||||
name: r.emailAddress.name,
|
||||
})) || [],
|
||||
snippet: message.bodyPreview,
|
||||
bodyPlain: message.body?.contentType === 'text' ? message.body.content : undefined,
|
||||
bodyHtml: message.body?.contentType === 'html' ? message.body.content : undefined,
|
||||
sentAt: message.sentDateTime ? new Date(message.sentDateTime) : undefined,
|
||||
receivedAt: message.receivedDateTime ? new Date(message.receivedDateTime) : undefined,
|
||||
isRead: message.isRead ?? false,
|
||||
isStarred: message.flag?.flagStatus === 'flagged',
|
||||
hasAttachments: message.hasAttachments ?? false,
|
||||
};
|
||||
}
|
||||
}
|
||||
38
apps-archived/mail/apps/backend/src/sync/sync.controller.ts
Normal file
38
apps-archived/mail/apps/backend/src/sync/sync.controller.ts
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
import { Controller, Post, Param, UseGuards, ParseUUIDPipe } from '@nestjs/common';
|
||||
import { JwtAuthGuard, CurrentUser, CurrentUserData } from '@manacore/shared-nestjs-auth';
|
||||
import { SyncService } from './sync.service';
|
||||
|
||||
@Controller('sync')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class SyncController {
|
||||
constructor(private readonly syncService: SyncService) {}
|
||||
|
||||
@Post('accounts/:accountId')
|
||||
async syncAccount(
|
||||
@CurrentUser() user: CurrentUserData,
|
||||
@Param('accountId', ParseUUIDPipe) accountId: string
|
||||
) {
|
||||
const result = await this.syncService.syncAccount(accountId, user.userId);
|
||||
return result;
|
||||
}
|
||||
|
||||
@Post('accounts/:accountId/folders/:folderId')
|
||||
async syncFolder(
|
||||
@CurrentUser() user: CurrentUserData,
|
||||
@Param('accountId', ParseUUIDPipe) accountId: string,
|
||||
@Param('folderId', ParseUUIDPipe) folderId: string
|
||||
) {
|
||||
const result = await this.syncService.syncFolder(accountId, user.userId, folderId);
|
||||
return result;
|
||||
}
|
||||
|
||||
@Post('emails/:emailId/fetch')
|
||||
async fetchFullEmail(
|
||||
@CurrentUser() user: CurrentUserData,
|
||||
@Param('emailId', ParseUUIDPipe) emailId: string
|
||||
) {
|
||||
// Get the email to find its account
|
||||
await this.syncService.fetchFullEmail('', user.userId, emailId);
|
||||
return { success: true };
|
||||
}
|
||||
}
|
||||
16
apps-archived/mail/apps/backend/src/sync/sync.module.ts
Normal file
16
apps-archived/mail/apps/backend/src/sync/sync.module.ts
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { SyncController } from './sync.controller';
|
||||
import { SyncService } from './sync.service';
|
||||
import { ImapProvider } from './providers/imap.provider';
|
||||
import { GmailProvider } from './providers/gmail.provider';
|
||||
import { OutlookProvider } from './providers/outlook.provider';
|
||||
import { AccountModule } from '../account/account.module';
|
||||
import { AttachmentModule } from '../attachment/attachment.module';
|
||||
|
||||
@Module({
|
||||
imports: [AccountModule, AttachmentModule],
|
||||
controllers: [SyncController],
|
||||
providers: [SyncService, ImapProvider, GmailProvider, OutlookProvider],
|
||||
exports: [SyncService],
|
||||
})
|
||||
export class SyncModule {}
|
||||
425
apps-archived/mail/apps/backend/src/sync/sync.service.ts
Normal file
425
apps-archived/mail/apps/backend/src/sync/sync.service.ts
Normal file
|
|
@ -0,0 +1,425 @@
|
|||
import { Injectable, Inject, Logger } from '@nestjs/common';
|
||||
import { Cron, CronExpression } from '@nestjs/schedule';
|
||||
import { eq, and, isNull } from 'drizzle-orm';
|
||||
import { DATABASE_CONNECTION } from '../db/database.module';
|
||||
import { type Database } from '../db/connection';
|
||||
import {
|
||||
emailAccounts,
|
||||
emails,
|
||||
folders,
|
||||
type EmailAccount,
|
||||
type NewEmail,
|
||||
type NewFolder,
|
||||
} from '../db/schema';
|
||||
import { AccountService } from '../account/account.service';
|
||||
import { AttachmentService } from '../attachment/attachment.service';
|
||||
import { ImapProvider } from './providers/imap.provider';
|
||||
import { GmailProvider } from './providers/gmail.provider';
|
||||
import { OutlookProvider } from './providers/outlook.provider';
|
||||
import {
|
||||
type EmailProvider,
|
||||
type SyncState,
|
||||
type SyncResult,
|
||||
type FetchedEmail,
|
||||
type FetchedFolder,
|
||||
} from './interfaces/email-provider.interface';
|
||||
|
||||
@Injectable()
|
||||
export class SyncService {
|
||||
private readonly logger = new Logger(SyncService.name);
|
||||
private syncInProgress = new Set<string>();
|
||||
|
||||
constructor(
|
||||
@Inject(DATABASE_CONNECTION) private db: Database,
|
||||
private accountService: AccountService,
|
||||
private attachmentService: AttachmentService,
|
||||
private imapProvider: ImapProvider,
|
||||
private gmailProvider: GmailProvider,
|
||||
private outlookProvider: OutlookProvider
|
||||
) {}
|
||||
|
||||
// Run sync every 5 minutes
|
||||
@Cron(CronExpression.EVERY_5_MINUTES)
|
||||
async scheduledSync() {
|
||||
this.logger.log('Starting scheduled sync');
|
||||
|
||||
const accounts = await this.db
|
||||
.select()
|
||||
.from(emailAccounts)
|
||||
.where(eq(emailAccounts.syncEnabled, true));
|
||||
|
||||
for (const account of accounts) {
|
||||
try {
|
||||
await this.syncAccount(account.id, account.userId);
|
||||
} catch (error) {
|
||||
this.logger.error(`Scheduled sync failed for account ${account.id}:`, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async syncAccount(accountId: string, userId: string): Promise<SyncResult> {
|
||||
// Prevent concurrent syncs for the same account
|
||||
if (this.syncInProgress.has(accountId)) {
|
||||
return {
|
||||
success: false,
|
||||
newEmails: 0,
|
||||
updatedEmails: 0,
|
||||
deletedEmails: 0,
|
||||
newFolders: 0,
|
||||
error: 'Sync already in progress',
|
||||
newSyncState: {},
|
||||
};
|
||||
}
|
||||
|
||||
this.syncInProgress.add(accountId);
|
||||
|
||||
try {
|
||||
const account = await this.accountService.findById(accountId, userId);
|
||||
if (!account) {
|
||||
throw new Error('Account not found');
|
||||
}
|
||||
|
||||
const provider = this.getProvider(account.provider);
|
||||
const password =
|
||||
account.provider === 'imap'
|
||||
? await this.accountService.getDecryptedPassword(accountId, userId)
|
||||
: undefined;
|
||||
|
||||
await provider.connect(account, password || undefined);
|
||||
|
||||
try {
|
||||
// Sync folders first
|
||||
const fetchedFolders = await provider.syncFolders(account);
|
||||
await this.saveFolders(account, fetchedFolders);
|
||||
|
||||
// Sync emails
|
||||
const syncState: SyncState = (account.syncState as SyncState) || {};
|
||||
const result = await provider.sync(account, syncState);
|
||||
|
||||
// Update account sync state
|
||||
await this.db
|
||||
.update(emailAccounts)
|
||||
.set({
|
||||
syncState: result.newSyncState,
|
||||
lastSyncAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(emailAccounts.id, accountId));
|
||||
|
||||
return result;
|
||||
} finally {
|
||||
await provider.disconnect();
|
||||
}
|
||||
} catch (error) {
|
||||
this.logger.error(`Sync failed for account ${accountId}:`, error);
|
||||
return {
|
||||
success: false,
|
||||
newEmails: 0,
|
||||
updatedEmails: 0,
|
||||
deletedEmails: 0,
|
||||
newFolders: 0,
|
||||
error: error instanceof Error ? error.message : 'Sync failed',
|
||||
newSyncState: {},
|
||||
};
|
||||
} finally {
|
||||
this.syncInProgress.delete(accountId);
|
||||
}
|
||||
}
|
||||
|
||||
async syncFolder(
|
||||
accountId: string,
|
||||
userId: string,
|
||||
folderId: string
|
||||
): Promise<{ emails: number }> {
|
||||
const account = await this.accountService.findById(accountId, userId);
|
||||
if (!account) {
|
||||
throw new Error('Account not found');
|
||||
}
|
||||
|
||||
const [folder] = await this.db
|
||||
.select()
|
||||
.from(folders)
|
||||
.where(and(eq(folders.id, folderId), eq(folders.userId, userId)));
|
||||
|
||||
if (!folder) {
|
||||
throw new Error('Folder not found');
|
||||
}
|
||||
|
||||
const provider = this.getProvider(account.provider);
|
||||
const password =
|
||||
account.provider === 'imap'
|
||||
? await this.accountService.getDecryptedPassword(accountId, userId)
|
||||
: undefined;
|
||||
|
||||
await provider.connect(account, password || undefined);
|
||||
|
||||
try {
|
||||
const fetchedEmails = await provider.fetchEmails(account, folder.path, { limit: 50 });
|
||||
await this.saveEmails(account, folder.id, fetchedEmails);
|
||||
|
||||
return { emails: fetchedEmails.length };
|
||||
} finally {
|
||||
await provider.disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
async fetchFullEmail(accountId: string, userId: string, emailId: string): Promise<void> {
|
||||
const account = await this.accountService.findById(accountId, userId);
|
||||
if (!account) {
|
||||
throw new Error('Account not found');
|
||||
}
|
||||
|
||||
const [email] = await this.db
|
||||
.select()
|
||||
.from(emails)
|
||||
.where(and(eq(emails.id, emailId), eq(emails.userId, userId)));
|
||||
|
||||
if (!email || !email.externalId) {
|
||||
throw new Error('Email not found');
|
||||
}
|
||||
|
||||
const provider = this.getProvider(account.provider);
|
||||
const password =
|
||||
account.provider === 'imap'
|
||||
? await this.accountService.getDecryptedPassword(accountId, userId)
|
||||
: undefined;
|
||||
|
||||
await provider.connect(account, password || undefined);
|
||||
|
||||
try {
|
||||
const fullEmail = await provider.fetchEmail(account, email.externalId);
|
||||
if (fullEmail) {
|
||||
// Update email with full body
|
||||
await this.db
|
||||
.update(emails)
|
||||
.set({
|
||||
bodyPlain: fullEmail.bodyPlain,
|
||||
bodyHtml: fullEmail.bodyHtml,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(emails.id, emailId));
|
||||
|
||||
// Save attachments
|
||||
if (fullEmail.attachments) {
|
||||
for (const att of fullEmail.attachments) {
|
||||
if (att.content) {
|
||||
await this.attachmentService.uploadDirect(userId, emailId, {
|
||||
filename: att.filename,
|
||||
mimeType: att.mimeType,
|
||||
content: att.content,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
await provider.disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
async updateEmailFlags(
|
||||
accountId: string,
|
||||
userId: string,
|
||||
emailId: string,
|
||||
flags: { isRead?: boolean; isStarred?: boolean }
|
||||
): Promise<void> {
|
||||
const account = await this.accountService.findById(accountId, userId);
|
||||
if (!account) {
|
||||
throw new Error('Account not found');
|
||||
}
|
||||
|
||||
const [email] = await this.db
|
||||
.select()
|
||||
.from(emails)
|
||||
.where(and(eq(emails.id, emailId), eq(emails.userId, userId)));
|
||||
|
||||
if (!email || !email.externalId) {
|
||||
throw new Error('Email not found');
|
||||
}
|
||||
|
||||
const provider = this.getProvider(account.provider);
|
||||
const password =
|
||||
account.provider === 'imap'
|
||||
? await this.accountService.getDecryptedPassword(accountId, userId)
|
||||
: undefined;
|
||||
|
||||
await provider.connect(account, password || undefined);
|
||||
|
||||
try {
|
||||
await provider.updateFlags(account, email.externalId, flags);
|
||||
} finally {
|
||||
await provider.disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
async moveEmail(
|
||||
accountId: string,
|
||||
userId: string,
|
||||
emailId: string,
|
||||
targetFolderId: string
|
||||
): Promise<void> {
|
||||
const account = await this.accountService.findById(accountId, userId);
|
||||
if (!account) {
|
||||
throw new Error('Account not found');
|
||||
}
|
||||
|
||||
const [email] = await this.db
|
||||
.select()
|
||||
.from(emails)
|
||||
.where(and(eq(emails.id, emailId), eq(emails.userId, userId)));
|
||||
|
||||
if (!email || !email.externalId) {
|
||||
throw new Error('Email not found');
|
||||
}
|
||||
|
||||
const [targetFolder] = await this.db
|
||||
.select()
|
||||
.from(folders)
|
||||
.where(and(eq(folders.id, targetFolderId), eq(folders.userId, userId)));
|
||||
|
||||
if (!targetFolder) {
|
||||
throw new Error('Target folder not found');
|
||||
}
|
||||
|
||||
const provider = this.getProvider(account.provider);
|
||||
const password =
|
||||
account.provider === 'imap'
|
||||
? await this.accountService.getDecryptedPassword(accountId, userId)
|
||||
: undefined;
|
||||
|
||||
await provider.connect(account, password || undefined);
|
||||
|
||||
try {
|
||||
await provider.moveEmail(account, email.externalId, targetFolder.path);
|
||||
} finally {
|
||||
await provider.disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
async deleteEmail(accountId: string, userId: string, emailId: string): Promise<void> {
|
||||
const account = await this.accountService.findById(accountId, userId);
|
||||
if (!account) {
|
||||
throw new Error('Account not found');
|
||||
}
|
||||
|
||||
const [email] = await this.db
|
||||
.select()
|
||||
.from(emails)
|
||||
.where(and(eq(emails.id, emailId), eq(emails.userId, userId)));
|
||||
|
||||
if (!email || !email.externalId) {
|
||||
throw new Error('Email not found');
|
||||
}
|
||||
|
||||
const provider = this.getProvider(account.provider);
|
||||
const password =
|
||||
account.provider === 'imap'
|
||||
? await this.accountService.getDecryptedPassword(accountId, userId)
|
||||
: undefined;
|
||||
|
||||
await provider.connect(account, password || undefined);
|
||||
|
||||
try {
|
||||
await provider.deleteEmail(account, email.externalId);
|
||||
} finally {
|
||||
await provider.disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
private getProvider(providerType: string): EmailProvider {
|
||||
switch (providerType) {
|
||||
case 'imap':
|
||||
return this.imapProvider;
|
||||
case 'gmail':
|
||||
return this.gmailProvider;
|
||||
case 'outlook':
|
||||
return this.outlookProvider;
|
||||
default:
|
||||
throw new Error(`Unknown provider type: ${providerType}`);
|
||||
}
|
||||
}
|
||||
|
||||
private async saveFolders(account: EmailAccount, fetchedFolders: FetchedFolder[]): Promise<void> {
|
||||
for (const fetched of fetchedFolders) {
|
||||
// Check if folder exists
|
||||
const [existing] = await this.db
|
||||
.select()
|
||||
.from(folders)
|
||||
.where(and(eq(folders.accountId, account.id), eq(folders.path, fetched.path)));
|
||||
|
||||
if (!existing) {
|
||||
await this.db.insert(folders).values({
|
||||
accountId: account.id,
|
||||
userId: account.userId,
|
||||
name: fetched.name,
|
||||
type: fetched.type,
|
||||
path: fetched.path,
|
||||
isSystem: ['inbox', 'sent', 'drafts', 'trash', 'spam'].includes(fetched.type),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async saveEmails(
|
||||
account: EmailAccount,
|
||||
folderId: string,
|
||||
fetchedEmails: FetchedEmail[]
|
||||
): Promise<void> {
|
||||
for (const fetched of fetchedEmails) {
|
||||
// Check if email exists by messageId
|
||||
const [existing] = await this.db
|
||||
.select()
|
||||
.from(emails)
|
||||
.where(and(eq(emails.accountId, account.id), eq(emails.messageId, fetched.messageId)));
|
||||
|
||||
if (!existing) {
|
||||
await this.db.insert(emails).values({
|
||||
accountId: account.id,
|
||||
folderId,
|
||||
userId: account.userId,
|
||||
messageId: fetched.messageId,
|
||||
externalId: fetched.externalId,
|
||||
threadId: fetched.threadId
|
||||
? await this.getOrCreateThreadId(account.id, fetched.threadId)
|
||||
: null,
|
||||
subject: fetched.subject,
|
||||
fromAddress: fetched.fromAddress,
|
||||
fromName: fetched.fromName,
|
||||
toAddresses: fetched.toAddresses,
|
||||
ccAddresses: fetched.ccAddresses,
|
||||
snippet: fetched.snippet,
|
||||
bodyPlain: fetched.bodyPlain,
|
||||
bodyHtml: fetched.bodyHtml,
|
||||
sentAt: fetched.sentAt,
|
||||
receivedAt: fetched.receivedAt,
|
||||
isRead: fetched.isRead,
|
||||
isStarred: fetched.isStarred,
|
||||
hasAttachments: fetched.hasAttachments,
|
||||
});
|
||||
} else {
|
||||
// Update existing email flags
|
||||
await this.db
|
||||
.update(emails)
|
||||
.set({
|
||||
isRead: fetched.isRead,
|
||||
isStarred: fetched.isStarred,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(emails.id, existing.id));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async getOrCreateThreadId(accountId: string, externalThreadId: string): Promise<string> {
|
||||
// Find existing email with same external thread ID
|
||||
const [existingEmail] = await this.db
|
||||
.select()
|
||||
.from(emails)
|
||||
.where(eq(emails.accountId, accountId))
|
||||
.limit(1);
|
||||
|
||||
// For simplicity, we generate a new UUID for each thread
|
||||
// In a real implementation, you'd want to track thread IDs properly
|
||||
return externalThreadId;
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue