huhuih
hzgjuigik
This commit is contained in:
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user