chahinebrini 335945fe2c feat(tier): plan limits Rev.2 + downgrade reconciliation + change-preview (Phase 2 backend)
- plan-features.ts: globalBlocklist 'curated'|'full' (curated = 30-domain stub,
  TODO real ~1-2k HaGeZi subset); maxAppDevices vs maxProtectedDevices split
  (legend maxProtectedDevices: 2); mail 1/3/Infinity
- limit-enforcement structured errors on mail/connect, custom-domains/add, devices/enroll
  ({ error:'plan_limit', resource, current, limit }); approved-own-submissions already
  excluded from custom-domain count (slot frees on approval)
- server/utils/downgrade-reconciliation.ts: founding-member exemption; re-upgrade
  reactivates paused mail + degraded devices; downgrade pauses newest-N mail accounts
  (isActive=false, pausedAt, pausedReason; pre-pause sets nextScanAt=now for a final
  sweep — real direct IMAP scan is TODO/stub); degrades excess device profiles
  (status='degraded', degradedAt); free → globalBlocklistGraceUntil = now+14d;
  custom domains grandfathered
- set-plan.post.ts + stripe/webhook.post.ts: run reconciliation on plan change;
  set-plan accepts { foundingMember } for testing
- GET /api/plan/change-preview?to=<plan>: gains/keeps/changes per resource (8 axes),
  founding-member → direction 'same'
- me.get.ts: + foundingMember, globalBlocklistGraceUntil, planLimits block
- blocklist + mail-scan honour globalBlocklistGraceUntil (grace → treat as 'full')
- db: countMailConnections/getMailConnections exclude paused; getAllMailConnections;
  getDeviceBlocklistMode (active|grace|passthrough|revoked)
- migration 20260511_tier_system_phase2 (profiles.founding_member +
  global_blocklist_grace_until; mail_connections.paused_at/paused_reason;
  protected_devices.degraded_at). prisma generate + build:backend clean.

TODOs (separate tickets): founding-member auto-counter on signup; real direct IMAP
final-scan (not just nextScanAt nudge); real curated blocklist data + wiring the
stub into the blocklist response for free users.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 16:23:02 +02:00

106 lines
3.2 KiB
TypeScript

import { ImapFlow } from "imapflow";
import { getProfile } from "../../db/profile";
import { getPlanLimits } from "../../utils/plan-features";
import { countMailConnections, upsertMailConnection } from "../../db/mail";
import { detectImapProviderAsync } from "../../utils/imap-providers";
/**
* POST /api/mail/connect
* Body: { email, password }
* Testet IMAP-Verbindung und speichert Credentials verschlüsselt.
*/
export default defineEventHandler(async (event) => {
const user = await requireUser(event);
const {
email,
password,
// Custom-IMAP-Felder (optional, nur wenn User eigenen Server konfiguriert)
imapHost: customImapHost,
imapPort: customImapPort,
useTls,
rejectUnauthorized,
} = await readBody(event);
if (!email || !password) {
throw createError({
statusCode: 400,
message: "Email und Passwort erforderlich",
});
}
// Plan-Limit prüfen
const profile = await getProfile(user.id);
const limits = getPlanLimits(profile?.plan ?? "free");
if (limits.mailAgents !== Infinity) {
const count = await countMailConnections(user.id);
if (count >= limits.mailAgents) {
throw createError({
statusCode: 403,
data: {
error: "plan_limit",
resource: "mail_accounts",
current: count,
limit: limits.mailAgents,
},
});
}
}
// Custom-IMAP: wenn imapHost explizit gesetzt → Provider-Detection überspringen.
// Sonst: automatisch via Email-Domain erkennen (inkl. MX-Lookup-Fallback für Custom-Domains).
const provider = await detectImapProviderAsync(email);
const resolvedHost = customImapHost?.trim() || provider.host;
const resolvedPort = customImapPort ?? provider.port;
// TLS-Konfiguration ableiten
// useTls=false → STARTTLS (secure=false, requireTLS=true bei ImapFlow)
// useTls=true oder nicht gesetzt → implicit TLS (secure=true)
const useImplicitTls = useTls !== false; // default: true
const tlsRejectUnauthorized = rejectUnauthorized !== false; // default: true
// STARTTLS nur wenn explizit angefordert (useTls === false)
const useStarttls = useTls === false;
// IMAP-Verbindung testen
const client = new ImapFlow({
host: resolvedHost,
port: resolvedPort,
secure: useImplicitTls,
...(useStarttls ? { requireTLS: true } : {}),
auth: { user: email, pass: password },
logger: false,
tls: { rejectUnauthorized: tlsRejectUnauthorized },
});
try {
await client.connect();
await client.logout();
} catch (err: any) {
throw createError({
statusCode: 401,
message: `Verbindung fehlgeschlagen: ${err.message ?? "Ungültige Zugangsdaten"}`,
});
}
// Credentials verschlüsselt speichern
await upsertMailConnection({
userId: user.id,
email,
provider: "imap",
// Bei Custom-Host: Host als providerName, sonst auto-erkannter Name
providerName: customImapHost ? resolvedHost : provider.name,
imapHost: resolvedHost,
imapPort: resolvedPort,
passwordEncrypted: encrypt(password),
rejectUnauthorized: tlsRejectUnauthorized,
useStarttls,
});
return {
connected: true,
email,
provider: customImapHost ? resolvedHost : provider.name,
custom: !!customImapHost,
};
});