hzgjuigik
This commit is contained in:
2026-01-27 21:06:48 +01:00
parent 18c11d27bc
commit 6da8ce1cbd
51 changed files with 6208 additions and 974 deletions

View File

@@ -234,6 +234,121 @@ export class AISorterService {
return null
}
/**
* Generate suggested rules based on email patterns
* Analyzes email samples to detect patterns and suggest rules
*/
async generateSuggestedRules(userId, emailSamples) {
if (!emailSamples || emailSamples.length === 0) {
return []
}
const suggestions = []
const senderCounts = {}
const domainCounts = {}
const subjectPatterns = {}
const categoryPatterns = {}
// Analyze patterns
for (const email of emailSamples) {
const from = email.from?.toLowerCase() || ''
const subject = email.subject?.toLowerCase() || ''
// Extract domain
const emailMatch = from.match(/@([^\s>]+)/)
if (emailMatch) {
const domain = emailMatch[1].toLowerCase()
domainCounts[domain] = (domainCounts[domain] || 0) + 1
}
// Count senders
const senderEmail = from.split('<')[1]?.split('>')[0] || from
senderCounts[senderEmail] = (senderCounts[senderEmail] || 0) + 1
// Detect category patterns
const category = email.category || 'review'
categoryPatterns[category] = (categoryPatterns[category] || 0) + 1
}
const totalEmails = emailSamples.length
const threshold = Math.max(3, Math.ceil(totalEmails * 0.1)) // At least 3 emails or 10% of total
// Suggest VIP senders (frequent senders)
const frequentSenders = Object.entries(senderCounts)
.filter(([_, count]) => count >= threshold)
.sort(([_, a], [__, b]) => b - a)
.slice(0, 3)
for (const [sender, count] of frequentSenders) {
suggestions.push({
type: 'vip_sender',
name: `Mark ${sender.split('@')[0]} as VIP`,
description: `${count} emails from this sender`,
confidence: Math.min(0.9, count / totalEmails),
action: {
type: 'add_vip',
email: sender,
},
})
}
// Suggest company labels (frequent domains)
const frequentDomains = Object.entries(domainCounts)
.filter(([domain, count]) => count >= threshold && !KNOWN_COMPANIES[domain])
.sort(([_, a], [__, b]) => b - a)
.slice(0, 3)
for (const [domain, count] of frequentDomains) {
const companyName = domain.split('.')[0].charAt(0).toUpperCase() + domain.split('.')[0].slice(1)
suggestions.push({
type: 'company_label',
name: `Label ${companyName} emails`,
description: `${count} emails from ${domain}`,
confidence: Math.min(0.85, count / totalEmails),
action: {
type: 'add_company_label',
name: companyName,
condition: `from:${domain}`,
category: 'promotions', // Default, user can change
},
})
}
// Suggest category-specific rules based on patterns
if (categoryPatterns.newsletters >= threshold) {
suggestions.push({
type: 'category_rule',
name: 'Archive newsletters automatically',
description: `${categoryPatterns.newsletters} newsletter emails detected`,
confidence: 0.8,
action: {
type: 'enable_category',
category: 'newsletters',
action: 'archive_read',
},
})
}
if (categoryPatterns.promotions >= threshold) {
suggestions.push({
type: 'category_rule',
name: 'Archive promotions automatically',
description: `${categoryPatterns.promotions} promotion emails detected`,
confidence: 0.75,
action: {
type: 'enable_category',
category: 'promotions',
action: 'archive_read',
},
})
}
// Sort by confidence and return top 5
return suggestions
.sort((a, b) => b.confidence - a.confidence)
.slice(0, 5)
}
/**
* Check if email matches a company label condition
*/

View File

@@ -30,6 +30,10 @@ export const Collections = {
EMAIL_DIGESTS: 'email_digests',
SUBSCRIPTIONS: 'subscriptions',
USER_PREFERENCES: 'user_preferences',
ONBOARDING_STATE: 'onboarding_state',
EMAIL_USAGE: 'email_usage',
REFERRALS: 'referrals',
ANALYTICS_EVENTS: 'analytics_events',
}
/**
@@ -251,12 +255,86 @@ export const emailStats = {
},
}
/**
* Email usage operations
*/
export const emailUsage = {
async getCurrentMonth(userId) {
const month = new Date().toISOString().slice(0, 7) // "2026-01"
return db.findOne(Collections.EMAIL_USAGE, [
Query.equal('userId', userId),
Query.equal('month', month),
])
},
async increment(userId, count) {
const month = new Date().toISOString().slice(0, 7)
const existing = await this.getCurrentMonth(userId)
if (existing) {
return db.update(Collections.EMAIL_USAGE, existing.$id, {
emailsProcessed: (existing.emailsProcessed || 0) + count,
lastReset: new Date().toISOString(),
})
}
return db.create(Collections.EMAIL_USAGE, {
userId,
month,
emailsProcessed: count,
lastReset: new Date().toISOString(),
})
},
async getUsage(userId) {
const usage = await this.getCurrentMonth(userId)
return {
emailsProcessed: usage?.emailsProcessed || 0,
month: new Date().toISOString().slice(0, 7),
}
},
}
/**
* Subscriptions operations
*/
export const subscriptions = {
async getByUser(userId) {
return db.findOne(Collections.SUBSCRIPTIONS, [Query.equal('userId', userId)])
const subscription = await db.findOne(Collections.SUBSCRIPTIONS, [Query.equal('userId', userId)])
// If no subscription, user is on free tier
if (!subscription) {
const usage = await emailUsage.getUsage(userId)
return {
plan: 'free',
status: 'active',
isFreeTier: true,
emailsUsedThisMonth: usage.emailsProcessed,
emailsLimit: 500, // From config
}
}
// Check if subscription is active
const isActive = subscription.status === 'active'
const isFreeTier = !isActive || subscription.plan === 'free'
// Get usage for free tier users
let emailsUsedThisMonth = 0
let emailsLimit = -1 // Unlimited for paid
if (isFreeTier) {
const usage = await emailUsage.getUsage(userId)
emailsUsedThisMonth = usage.emailsProcessed
emailsLimit = 500 // From config
}
return {
...subscription,
plan: subscription.plan || 'free',
isFreeTier,
emailsUsedThisMonth,
emailsLimit,
}
},
async getByStripeId(stripeSubscriptionId) {
@@ -296,6 +374,27 @@ export const userPreferences = {
categoryActions: {},
companyLabels: [],
autoDetectCompanies: true,
version: 1,
categoryAdvanced: {},
cleanup: {
enabled: false,
readItems: {
enabled: false,
action: 'archive_read',
gracePeriodDays: 7,
},
promotions: {
enabled: false,
matchCategoriesOrLabels: ['promotions', 'newsletters'],
action: 'archive_read',
deleteAfterDays: 30,
},
safety: {
requireConfirmForDelete: true,
dryRun: false,
maxDeletesPerRun: 100,
},
},
}
},
@@ -347,6 +446,170 @@ export const userPreferences = {
},
}
/**
* Onboarding state operations
*/
export const onboardingState = {
async getByUser(userId) {
const state = await db.findOne(Collections.ONBOARDING_STATE, [
Query.equal('userId', userId),
])
if (state?.completed_steps_json) {
return {
...state,
completedSteps: JSON.parse(state.completed_steps_json),
}
}
return {
...state,
completedSteps: [],
onboarding_step: state?.onboarding_step || 'not_started',
}
},
async updateStep(userId, step, completedSteps = []) {
const existing = await db.findOne(Collections.ONBOARDING_STATE, [
Query.equal('userId', userId),
])
const data = {
onboarding_step: step,
completed_steps_json: JSON.stringify(completedSteps),
last_updated: new Date().toISOString(),
}
if (existing) {
return db.update(Collections.ONBOARDING_STATE, existing.$id, data)
}
return db.create(Collections.ONBOARDING_STATE, { userId, ...data })
},
async markValueSeen(userId) {
const existing = await db.findOne(Collections.ONBOARDING_STATE, [
Query.equal('userId', userId),
])
const data = {
first_value_seen_at: new Date().toISOString(),
last_updated: new Date().toISOString(),
}
if (existing) {
return db.update(Collections.ONBOARDING_STATE, existing.$id, data)
}
return db.create(Collections.ONBOARDING_STATE, {
userId,
onboarding_step: 'see_results',
completed_steps_json: JSON.stringify(['connect', 'first_rule', 'see_results']),
...data,
})
},
async skip(userId) {
const existing = await db.findOne(Collections.ONBOARDING_STATE, [
Query.equal('userId', userId),
])
const data = {
skipped_at: new Date().toISOString(),
last_updated: new Date().toISOString(),
}
if (existing) {
return db.update(Collections.ONBOARDING_STATE, existing.$id, data)
}
return db.create(Collections.ONBOARDING_STATE, {
userId,
onboarding_step: 'not_started',
completed_steps_json: JSON.stringify([]),
...data,
})
},
async resume(userId) {
const existing = await db.findOne(Collections.ONBOARDING_STATE, [
Query.equal('userId', userId),
])
if (existing) {
return db.update(Collections.ONBOARDING_STATE, existing.$id, {
skipped_at: null,
last_updated: new Date().toISOString(),
})
}
// If no state exists, create initial state
return db.create(Collections.ONBOARDING_STATE, {
userId,
onboarding_step: 'connect',
completed_steps_json: JSON.stringify([]),
last_updated: new Date().toISOString(),
})
},
}
/**
* Referrals operations
*/
export const referrals = {
async getOrCreateCode(userId) {
const existing = await db.findOne(Collections.REFERRALS, [
Query.equal('userId', userId),
])
if (existing) {
return existing
}
// Generate unique code: USER-ABC123
const randomPart = Math.random().toString(36).substring(2, 8).toUpperCase()
const code = `USER-${randomPart}`
// Ensure uniqueness
let uniqueCode = code
let attempts = 0
while (attempts < 10) {
const existingCode = await db.findOne(Collections.REFERRALS, [
Query.equal('referralCode', uniqueCode),
])
if (!existingCode) break
uniqueCode = `USER-${Math.random().toString(36).substring(2, 8).toUpperCase()}`
attempts++
}
return db.create(Collections.REFERRALS, {
userId,
referralCode: uniqueCode,
referralCount: 0,
createdAt: new Date().toISOString(),
})
},
async getByCode(code) {
return db.findOne(Collections.REFERRALS, [
Query.equal('referralCode', code),
])
},
async incrementCount(userId) {
const referral = await db.findOne(Collections.REFERRALS, [
Query.equal('userId', userId),
])
if (referral) {
return db.update(Collections.REFERRALS, referral.$id, {
referralCount: (referral.referralCount || 0) + 1,
})
}
return null
},
async getReferrals(userId) {
return db.list(Collections.REFERRALS, [
Query.equal('referredBy', userId),
])
},
}
/**
* Orders operations
*/
@@ -450,6 +713,8 @@ export const emailDigests = {
},
}
export { Query }
export default {
db,
products,