Merge branch 'dev-1' into dev

This commit is contained in:
Wuesteon 2025-12-05 17:57:26 +01:00
commit d41d060bb3
1770 changed files with 168028 additions and 31031 deletions

View file

@ -0,0 +1,38 @@
import { drizzle } from 'drizzle-orm/postgres-js';
import * as schema from './schema';
// Use require for postgres to avoid ESM/CommonJS interop issues
// eslint-disable-next-line @typescript-eslint/no-var-requires
const postgres = require('postgres');
let connection: ReturnType<typeof postgres> | null = null;
let db: ReturnType<typeof drizzle> | null = null;
export function getConnection(databaseUrl: string) {
if (!connection) {
connection = postgres(databaseUrl, {
max: 10,
idle_timeout: 20,
connect_timeout: 10,
});
}
return connection;
}
export function getDb(databaseUrl: string) {
if (!db) {
const conn = getConnection(databaseUrl);
db = drizzle(conn, { schema });
}
return db;
}
export async function closeConnection() {
if (connection) {
await connection.end();
connection = null;
db = null;
}
}
export type Database = ReturnType<typeof getDb>;

View file

@ -0,0 +1,30 @@
import { Module, Global } from '@nestjs/common';
import type { OnModuleDestroy } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { getDb, closeConnection } from './connection';
import type { Database } from './connection';
export const DATABASE_CONNECTION = 'DATABASE_CONNECTION';
@Global()
@Module({
providers: [
{
provide: DATABASE_CONNECTION,
useFactory: (configService: ConfigService): Database => {
const databaseUrl = configService.get<string>('DATABASE_URL');
if (!databaseUrl) {
throw new Error('DATABASE_URL environment variable is not set');
}
return getDb(databaseUrl);
},
inject: [ConfigService],
},
],
exports: [DATABASE_CONNECTION],
})
export class DatabaseModule implements OnModuleDestroy {
async onModuleDestroy() {
await closeConnection();
}
}

View file

@ -0,0 +1,25 @@
import { pgTable, uuid, timestamp, varchar, integer, boolean } from 'drizzle-orm/pg-core';
import { emails } from './emails.schema';
export const attachments = pgTable('attachments', {
id: uuid('id').primaryKey().defaultRandom(),
emailId: uuid('email_id')
.references(() => emails.id, { onDelete: 'cascade' })
.notNull(),
userId: varchar('user_id', { length: 255 }).notNull(),
filename: varchar('filename', { length: 500 }).notNull(),
mimeType: varchar('mime_type', { length: 255 }).notNull(),
size: integer('size').notNull(),
contentId: varchar('content_id', { length: 255 }), // For inline images
// Storage
storageKey: varchar('storage_key', { length: 500 }),
storageUrl: varchar('storage_url', { length: 1000 }),
isDownloaded: boolean('is_downloaded').default(false),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
});
export type Attachment = typeof attachments.$inferSelect;
export type NewAttachment = typeof attachments.$inferInsert;

View file

@ -0,0 +1,33 @@
import { pgTable, uuid, timestamp, varchar, text, jsonb } from 'drizzle-orm/pg-core';
import { emailAccounts } from './email-accounts.schema';
import { emails, type EmailAddress } from './emails.schema';
export const drafts = pgTable('drafts', {
id: uuid('id').primaryKey().defaultRandom(),
accountId: uuid('account_id')
.references(() => emailAccounts.id, { onDelete: 'cascade' })
.notNull(),
userId: varchar('user_id', { length: 255 }).notNull(),
// Reply context
replyToEmailId: uuid('reply_to_email_id').references(() => emails.id, { onDelete: 'set null' }),
replyType: varchar('reply_type', { length: 20 }), // reply, reply-all, forward
// Content
subject: text('subject'),
toAddresses: jsonb('to_addresses').$type<EmailAddress[]>(),
ccAddresses: jsonb('cc_addresses').$type<EmailAddress[]>(),
bccAddresses: jsonb('bcc_addresses').$type<EmailAddress[]>(),
bodyHtml: text('body_html'),
bodyPlain: text('body_plain'),
attachmentIds: jsonb('attachment_ids').$type<string[]>(),
// Scheduling
scheduledAt: timestamp('scheduled_at', { withTimezone: true }),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
});
export type Draft = typeof drafts.$inferSelect;
export type NewDraft = typeof drafts.$inferInsert;

View file

@ -0,0 +1,63 @@
import {
pgTable,
uuid,
timestamp,
varchar,
text,
boolean,
integer,
jsonb,
} from 'drizzle-orm/pg-core';
export interface SyncState {
// IMAP sync state
uidValidity?: number;
lastUid?: number;
// Gmail sync state
historyId?: string;
// Outlook sync state
deltaLink?: string;
}
export const emailAccounts = pgTable('email_accounts', {
id: uuid('id').primaryKey().defaultRandom(),
userId: varchar('user_id', { length: 255 }).notNull(),
// Account info
name: varchar('name', { length: 255 }).notNull(),
email: varchar('email', { length: 255 }).notNull(),
provider: varchar('provider', { length: 50 }).notNull(), // gmail, outlook, imap
isDefault: boolean('is_default').default(false),
// IMAP/SMTP credentials (encrypted)
imapHost: varchar('imap_host', { length: 255 }),
imapPort: integer('imap_port'),
imapSecurity: varchar('imap_security', { length: 20 }), // ssl, tls, none
smtpHost: varchar('smtp_host', { length: 255 }),
smtpPort: integer('smtp_port'),
smtpSecurity: varchar('smtp_security', { length: 20 }),
encryptedPassword: text('encrypted_password'),
// OAuth tokens (Gmail/Outlook)
accessToken: text('access_token'),
refreshToken: text('refresh_token'),
tokenExpiresAt: timestamp('token_expires_at', { withTimezone: true }),
tokenScopes: jsonb('token_scopes').$type<string[]>(),
// Sync settings
syncEnabled: boolean('sync_enabled').default(true),
syncInterval: integer('sync_interval').default(5), // minutes
lastSyncAt: timestamp('last_sync_at', { withTimezone: true }),
lastSyncError: text('last_sync_error'),
syncState: jsonb('sync_state').$type<SyncState>(),
// Display settings
color: varchar('color', { length: 7 }).default('#3B82F6'),
signature: text('signature'),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
});
export type EmailAccount = typeof emailAccounts.$inferSelect;
export type NewEmailAccount = typeof emailAccounts.$inferInsert;

View file

@ -0,0 +1,88 @@
import {
pgTable,
uuid,
timestamp,
varchar,
text,
boolean,
integer,
jsonb,
index,
} from 'drizzle-orm/pg-core';
import { emailAccounts } from './email-accounts.schema';
import { folders } from './folders.schema';
export interface EmailAddress {
email: string;
name?: string;
}
export const emails = pgTable(
'emails',
{
id: uuid('id').primaryKey().defaultRandom(),
accountId: uuid('account_id')
.references(() => emailAccounts.id, { onDelete: 'cascade' })
.notNull(),
folderId: uuid('folder_id').references(() => folders.id, { onDelete: 'set null' }),
userId: varchar('user_id', { length: 255 }).notNull(),
threadId: uuid('thread_id'), // For conversation threading
// Message identifiers
messageId: varchar('message_id', { length: 500 }).notNull(), // RFC 2822 Message-ID
externalId: varchar('external_id', { length: 255 }), // Provider-specific ID
// Headers
subject: text('subject'),
fromAddress: varchar('from_address', { length: 255 }),
fromName: varchar('from_name', { length: 255 }),
toAddresses: jsonb('to_addresses').$type<EmailAddress[]>(),
ccAddresses: jsonb('cc_addresses').$type<EmailAddress[]>(),
bccAddresses: jsonb('bcc_addresses').$type<EmailAddress[]>(),
replyTo: varchar('reply_to', { length: 255 }),
inReplyTo: varchar('in_reply_to', { length: 500 }), // Parent message ID
references: jsonb('references').$type<string[]>(), // Thread references
// Content
snippet: text('snippet'), // Preview text (first ~200 chars)
bodyPlain: text('body_plain'),
bodyHtml: text('body_html'),
// Dates
sentAt: timestamp('sent_at', { withTimezone: true }),
receivedAt: timestamp('received_at', { withTimezone: true }),
// Flags
isRead: boolean('is_read').default(false),
isStarred: boolean('is_starred').default(false),
isDraft: boolean('is_draft').default(false),
isDeleted: boolean('is_deleted').default(false),
isSpam: boolean('is_spam').default(false),
hasAttachments: boolean('has_attachments').default(false),
// AI-generated metadata
aiSummary: text('ai_summary'),
aiCategory: varchar('ai_category', { length: 50 }), // work, personal, newsletter, etc.
aiPriority: varchar('ai_priority', { length: 20 }), // high, medium, low
aiSentiment: varchar('ai_sentiment', { length: 20 }), // positive, neutral, negative
aiSuggestedReplies: jsonb('ai_suggested_replies').$type<string[]>(),
// Size and metadata
size: integer('size'), // bytes
headers: jsonb('headers').$type<Record<string, string>>(),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
},
(table) => [
index('emails_account_id_idx').on(table.accountId),
index('emails_folder_id_idx').on(table.folderId),
index('emails_thread_id_idx').on(table.threadId),
index('emails_message_id_idx').on(table.messageId),
index('emails_received_at_idx').on(table.receivedAt),
index('emails_user_id_idx').on(table.userId),
]
);
export type Email = typeof emails.$inferSelect;
export type NewEmail = typeof emails.$inferInsert;

View file

@ -0,0 +1,33 @@
import { pgTable, uuid, timestamp, varchar, integer, boolean } from 'drizzle-orm/pg-core';
import { emailAccounts } from './email-accounts.schema';
export const folders = pgTable('folders', {
id: uuid('id').primaryKey().defaultRandom(),
accountId: uuid('account_id')
.references(() => emailAccounts.id, { onDelete: 'cascade' })
.notNull(),
userId: varchar('user_id', { length: 255 }).notNull(),
name: varchar('name', { length: 255 }).notNull(),
type: varchar('type', { length: 50 }).notNull(), // inbox, sent, drafts, trash, spam, archive, custom
path: varchar('path', { length: 500 }).notNull(), // IMAP folder path
color: varchar('color', { length: 7 }),
icon: varchar('icon', { length: 50 }),
// Provider-specific ID
externalId: varchar('external_id', { length: 255 }),
// Counts (cached)
totalCount: integer('total_count').default(0),
unreadCount: integer('unread_count').default(0),
// Flags
isSystem: boolean('is_system').default(false),
isHidden: boolean('is_hidden').default(false),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
});
export type Folder = typeof folders.$inferSelect;
export type NewFolder = typeof folders.$inferInsert;

View file

@ -0,0 +1,6 @@
export * from './email-accounts.schema';
export * from './folders.schema';
export * from './emails.schema';
export * from './attachments.schema';
export * from './labels.schema';
export * from './drafts.schema';

View file

@ -0,0 +1,30 @@
import { pgTable, uuid, timestamp, varchar, primaryKey } from 'drizzle-orm/pg-core';
import { emailAccounts } from './email-accounts.schema';
import { emails } from './emails.schema';
export const labels = pgTable('labels', {
id: uuid('id').primaryKey().defaultRandom(),
userId: varchar('user_id', { length: 255 }).notNull(),
accountId: uuid('account_id').references(() => emailAccounts.id, { onDelete: 'cascade' }),
name: varchar('name', { length: 100 }).notNull(),
color: varchar('color', { length: 7 }).notNull(),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
});
export const emailLabels = pgTable(
'email_labels',
{
emailId: uuid('email_id')
.references(() => emails.id, { onDelete: 'cascade' })
.notNull(),
labelId: uuid('label_id')
.references(() => labels.id, { onDelete: 'cascade' })
.notNull(),
},
(table) => [primaryKey({ columns: [table.emailId, table.labelId] })]
);
export type Label = typeof labels.$inferSelect;
export type NewLabel = typeof labels.$inferInsert;