- 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>
106 lines
3.2 KiB
TypeScript
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,
|
|
};
|
|
});
|