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:
Till-JS 2025-12-05 13:13:15 +01:00
parent c3c272abc9
commit ace7fa8f7f
427 changed files with 0 additions and 0 deletions

View file

@ -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>;
}

View file

@ -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+/),
};
}
}

View file

@ -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;
}
}

View file

@ -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,
};
}
}

View 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 };
}
}

View 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 {}

View 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;
}
}