diff --git a/CHANGELOG.md b/CHANGELOG.md index 6ea34d3..65337a6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,19 +19,24 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `GET /admin/users/{id}` – Get a single user's details. - `PUT /admin/users/{id}` – Update user details, plan, active status, and superuser flag. - `DELETE /admin/users/{id}` – Delete a user. - - `GET /admin/plans` – List all subscription plans (including inactive). + - `GET /admin/plans` – List all subscription plans (including zero-price / inactive). - `POST /admin/plans` – Create a new subscription plan. - `PUT /admin/plans/{id}` – Update a subscription plan. - `DELETE /admin/plans/{id}` – Delete a subscription plan. - **Admin badge in top bar**: Admin users see a purple shield icon and an "Admin" badge next to their email in the top navigation bar. +- **`DEFAULT_USER_TIER` env var**: Controls the subscription tier assigned to every new user on registration. Defaults to `free`. Set to `enterprise` (or any other tier) for B2B / Google Workspace installations where all employees should start on a zero-rate plan. +- **`ALLOWED_DOMAINS` env var**: Comma-separated list of permitted email domains (e.g. `company.com,subsidiary.com`). When set, only addresses from those domains may register or log in. Superusers always bypass this check. Empty (default) = no restriction (normal B2C mode). +- **Dynamic pricing section on landing page**: The home page now fetches `GET /subscriptions/plans` and renders a pricing section only when paid plans exist. In enterprise / all-zero-rate deployments the pricing section is silently hidden — the page just shows features and a "Get started free" CTA. +- **B2C copy and branding**: App renamed to **InboxRescue** throughout (was "POP3 Forwarder SaaS"). Landing page hero, feature cards, how-it-works, and footer rewritten in a personal, consumer-friendly tone. Pricing updated to €0.99 / €1.99 / €2.99 per month for Good / Better / Best plans. ### Fixed - Test email sender name corrected from "Christian Loris" to "Christian Krakau-Louis". - **Mailbox limit always hit at 1**: The `subscription_plans` table was never seeded, so the limit check fell back to the env-var default of `TIER_FREE_MAX_ACCOUNTS=1` for every user regardless of their tier. Fixed by: - 1. Seeding four default `SubscriptionPlan` rows at startup — **Free**, **Good**, **Better**, **Best** — so admin-managed limits are stored in the DB from first boot. + 1. Seeding four default `SubscriptionPlan` rows at startup — **Free**, **Good** (€0.99), **Better** (€1.99), **Best** (€2.99). 2. Rewriting the limit check to look up the user's active plan from the DB first, falling back to env-var config only when no plan row exists. 3. Bypassing the limit entirely for superusers (admins can always add mailboxes). - 4. Adding plan limit fields (`max_mail_accounts`, `max_emails_per_day`, `check_interval_minutes`) to the `GET /subscriptions/current` response so the frontend can display them. + 4. Adding plan limit fields (`max_mail_accounts`, `max_emails_per_day`, `check_interval_minutes`) to `GET /subscriptions/current`. +- **Zero-price plans hidden from public marketing**: `GET /subscriptions/plans` now only returns plans with `price_monthly > 0`. Zero-rate plans (Free tier, custom enterprise plans) are still managed by admins but never shown in the public pricing UI. ### Fixed - Fixed `TypeError: can't subtract offset-naive and offset-aware datetimes` in `process_mail_account` task when computing `duration_seconds`. After a database refresh, `started_at` may be returned as a naive datetime; it is now normalized to UTC before subtraction. diff --git a/backend/app/api/v1/endpoints/auth.py b/backend/app/api/v1/endpoints/auth.py index a5d6b4e..1c99855 100644 --- a/backend/app/api/v1/endpoints/auth.py +++ b/backend/app/api/v1/endpoints/auth.py @@ -33,6 +33,48 @@ GOOGLE_LOGIN_SCOPES = [ ] +def _domain_of(email: str) -> str: + """Return the lowercased domain part of an email address.""" + return email.split("@")[-1].lower() + + +def _check_domain_allowed(email: str) -> None: + """ + Raise 403 if ALLOWED_DOMAINS is configured and the email's domain is not + in the list. Always passes when ALLOWED_DOMAINS is empty (no restriction). + """ + if not settings.ALLOWED_DOMAINS: + return + domain = _domain_of(email) + if domain not in settings.ALLOWED_DOMAINS: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail=( + f"Registrations are restricted to approved domains. " + f"'{domain}' is not authorised." + ), + ) + + +def _default_tier() -> SubscriptionTier: + """Return the SubscriptionTier that should be assigned to every new user.""" + try: + return SubscriptionTier(settings.DEFAULT_USER_TIER) + except ValueError: + logger.warning( + "DEFAULT_USER_TIER '%s' is not a valid tier; falling back to FREE.", + settings.DEFAULT_USER_TIER, + ) + return SubscriptionTier.FREE + + +def _is_admin_email(email: str) -> bool: + return ( + settings.ADMIN_EMAIL is not None + and email.lower() == settings.ADMIN_EMAIL.lower() + ) + + @router.post( "/register", response_model=UserResponse, status_code=status.HTTP_201_CREATED ) @@ -48,6 +90,9 @@ async def register(user_in: UserCreate, db: AsyncSession = Depends(get_db)): status_code=status.HTTP_400_BAD_REQUEST, detail="Email already registered" ) + # Domain restriction check (before creating the account) + _check_domain_allowed(user_in.email) + # Create new user user = User( email=user_in.email, @@ -55,12 +100,9 @@ async def register(user_in: UserCreate, db: AsyncSession = Depends(get_db)): hashed_password=( get_password_hash(user_in.password) if user_in.password else None ), - subscription_tier=SubscriptionTier.FREE, + subscription_tier=_default_tier(), is_active=True, - is_superuser=( - settings.ADMIN_EMAIL is not None - and user_in.email.lower() == settings.ADMIN_EMAIL.lower() - ), + is_superuser=_is_admin_email(user_in.email), ) db.add(user) @@ -103,15 +145,15 @@ async def login( status_code=status.HTTP_403_FORBIDDEN, detail="User account is inactive" ) + # Domain restriction — superusers always bypass + if not user.is_superuser: + _check_domain_allowed(user.email) + # Update last login user.last_login_at = datetime.now(timezone.utc) # type: ignore[assignment] # Auto-promote to superuser if this is the configured admin email - if ( - settings.ADMIN_EMAIL is not None - and user.email.lower() == settings.ADMIN_EMAIL.lower() - and not user.is_superuser - ): + if not user.is_superuser and _is_admin_email(user.email): user.is_superuser = True # type: ignore[assignment] logger.info(f"Auto-promoted admin user: {user.email}") @@ -163,30 +205,30 @@ async def google_oauth( # Update last login user.last_login_at = datetime.now(timezone.utc) # type: ignore[assignment] + # Domain restriction — superusers always bypass + if not user.is_superuser: + _check_domain_allowed(email) + # Auto-promote to superuser if this is the configured admin email - if ( - settings.ADMIN_EMAIL is not None - and user.email.lower() == settings.ADMIN_EMAIL.lower() - and not user.is_superuser - ): + if not user.is_superuser and _is_admin_email(email): user.is_superuser = True # type: ignore[assignment] logger.info(f"Auto-promoted admin user via Google OAuth: {user.email}") logger.info(f"Existing user logged in with Google: {user.email}") else: + # Domain restriction check before creating the account + _check_domain_allowed(email) + # Create new user user = User( email=email, full_name=user_info.get("full_name"), google_id=google_id, oauth_provider="google", - subscription_tier=SubscriptionTier.FREE, + subscription_tier=_default_tier(), is_active=True, last_login_at=datetime.now(timezone.utc), - is_superuser=( - settings.ADMIN_EMAIL is not None - and email.lower() == settings.ADMIN_EMAIL.lower() - ), + is_superuser=_is_admin_email(email), ) db.add(user) diff --git a/backend/app/api/v1/endpoints/subscriptions.py b/backend/app/api/v1/endpoints/subscriptions.py index 8209f25..91d3d93 100644 --- a/backend/app/api/v1/endpoints/subscriptions.py +++ b/backend/app/api/v1/endpoints/subscriptions.py @@ -15,9 +15,18 @@ router = APIRouter() @router.get("/plans", response_model=List[SubscriptionPlanResponse]) async def list_subscription_plans(db: AsyncSession = Depends(get_db)): - """List all available subscription plans""" + """ + List subscription plans shown in marketing / pricing pages. + + Zero-price plans (price_monthly == 0) are intentionally excluded so that + enterprise / white-label deployments that assign a free plan to all users + don't surface that plan in the public pricing UI. + """ result = await db.execute( - select(SubscriptionPlan).where(SubscriptionPlan.is_active == True) # noqa: E712 + select(SubscriptionPlan).where( + SubscriptionPlan.is_active.is_(True), + SubscriptionPlan.price_monthly > 0, + ) ) return result.scalars().all() diff --git a/backend/app/core/config.py b/backend/app/core/config.py index 77e973b..2752c46 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -97,6 +97,15 @@ class Settings(BaseSettings): ADMIN_EMAIL: Optional[str] = "christianlouis@gmail.com" ADMIN_PASSWORD: Optional[str] = None + # User defaults & access control + # Tier assigned to every new user on registration: free | basic | pro | enterprise + DEFAULT_USER_TIER: str = "free" + # Comma-separated list of allowed email domains (empty = no restriction). + # When set, only addresses from these domains may register or log in. + # Useful for B2B / Google Workspace installations. + # Example: "company.com,subsidiary.com" + ALLOWED_DOMAINS: List[str] = [] + # Mail Server Presets MAIL_SERVER_PRESETS_FILE: str = "app/data/mail_server_presets.json" @@ -108,6 +117,23 @@ class Settings(BaseSettings): return [i.strip() for i in v.split(",")] return v + @field_validator("ALLOWED_DOMAINS", mode="before") + @classmethod + def assemble_allowed_domains(cls, v: str | List[str]) -> List[str]: + """Parse allowed domains from a comma-separated environment variable""" + if isinstance(v, str): + return [d.strip().lower() for d in v.split(",") if d.strip()] + return [d.lower() for d in v if d] + + @field_validator("DEFAULT_USER_TIER") + @classmethod + def validate_default_user_tier(cls, v: str) -> str: + """Ensure DEFAULT_USER_TIER is one of the known tier values""" + valid = {"free", "basic", "pro", "enterprise"} + if v.lower() not in valid: + raise ValueError(f"DEFAULT_USER_TIER must be one of {valid}, got '{v}'") + return v.lower() + @field_validator("SECRET_KEY") @classmethod def validate_secret_key(cls, v: str) -> str: diff --git a/backend/app/main.py b/backend/app/main.py index cd53368..31b2c53 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -65,7 +65,7 @@ def create_application() -> FastAPI: app = FastAPI( title=settings.APP_NAME, version=settings.APP_VERSION, - description="Multi-tenant POP3/IMAP to Gmail forwarder with subscription management", + description="Poll your legacy POP3/IMAP inboxes and deliver everything to Gmail. For real people, not enterprises.", docs_url="/api/docs", redoc_url="/api/redoc", openapi_url="/api/openapi.json", @@ -92,7 +92,7 @@ def create_application() -> FastAPI: async def root(): """Root endpoint""" return { - "message": "POP3 Forwarder SaaS API", + "message": "InboxRescue API", "version": settings.APP_VERSION, "docs": "/api/docs", } diff --git a/frontend/src/app/layout.tsx b/frontend/src/app/layout.tsx index 2667f15..b97ef9c 100644 --- a/frontend/src/app/layout.tsx +++ b/frontend/src/app/layout.tsx @@ -14,8 +14,8 @@ const geistMono = Geist_Mono({ }); export const metadata: Metadata = { - title: "POP3 Forwarder - Automatic Email Forwarding to Gmail", - description: "Forward your POP3 emails to Gmail automatically with our secure and reliable service", + title: "InboxRescue — your old inboxes, delivered to Gmail", + description: "Poll your legacy POP3 and IMAP mailboxes and have everything land quietly in Gmail. Set it once, forget it exists.", }; export default function RootLayout({ diff --git a/frontend/src/app/page.tsx b/frontend/src/app/page.tsx index 6127f1f..76ff207 100644 --- a/frontend/src/app/page.tsx +++ b/frontend/src/app/page.tsx @@ -4,8 +4,61 @@ import { useEffect } from 'react'; import Link from 'next/link'; import { useRouter } from 'next/navigation'; import { useAuthStore } from '@/store/authStore'; -import { userApi } from '@/lib/api'; -import { Mail, ArrowRight, Shield, Zap, Clock } from 'lucide-react'; +import { userApi, SubscriptionPlan } from '@/lib/api'; +import { useQuery } from '@tanstack/react-query'; +import api from '@/lib/api'; +import { Mail, ArrowRight, Shield, Zap, Clock, Check } from 'lucide-react'; + +async function fetchPublicPlans(): Promise { + const res = await api.get('/subscriptions/plans'); + return res.data; +} + +function PricingCard({ plan }: { plan: SubscriptionPlan }) { + const yearlyMonthly = plan.price_yearly + ? (plan.price_yearly / 12).toFixed(2) + : null; + + return ( +
+

{plan.name}

+

{plan.description}

+
+ + €{plan.price_monthly.toFixed(2)} + + /month + {yearlyMonthly && ( +

+ or €{yearlyMonthly}/mo billed yearly (€{plan.price_yearly?.toFixed(2)}) +

+ )} +
+
    +
  • + + {plan.max_mail_accounts === 1 + ? '1 mailbox' + : `Up to ${plan.max_mail_accounts} mailboxes`} +
  • +
  • + + Checked every {plan.check_interval_minutes} minute{plan.check_interval_minutes > 1 ? 's' : ''} +
  • +
  • + + Up to {plan.max_emails_per_day.toLocaleString()} emails/day +
  • +
+ + Get started + +
+ ); +} export default function Home() { const router = useRouter(); @@ -29,6 +82,12 @@ export default function Home() { }); }, [router, setUser, setLoading]); + const { data: plans } = useQuery({ + queryKey: ['public-plans'], + queryFn: fetchPublicPlans, + staleTime: 5 * 60 * 1000, + }); + if (isLoading) { return (
@@ -37,6 +96,8 @@ export default function Home() { ); } + const hasPaidPlans = plans && plans.length > 0; + return (
{/* Header */} @@ -45,7 +106,7 @@ export default function Home() {
-

POP3 Forwarder

+

InboxRescue

- Sign Up + Sign Up Free
- {/* Hero Section */}
+ + {/* Hero */}

- Forward Your POP3 Emails to Gmail -
- Automatically + Your old inboxes,{' '} + delivered to Gmail.

-

- Connect your POP3 email accounts and automatically forward all messages to Gmail. - Simple, secure, and reliable email forwarding service. +

+ You know the ones — that GMX account from 2009, the old ISP address your + bank still sends to, the Hotmail you gave out in school. InboxRescue + quietly polls them all and drops everything into your Gmail. Set it once, + forget it exists.

-
+

+ No forwarding rules to configure. No email clients to keep open. + Just your mail, where you actually read it. +

+
- Get Started Free + Get Started — it's free

- Auto-Detection + Auto-detects everything

- Automatically detect POP3 server settings from your email address. - Quick and easy setup in minutes. + Type your old email address and InboxRescue figures out the server + settings. No Googling port numbers required.

@@ -114,11 +181,11 @@ export default function Home() {

- Scheduled Checks + Runs in the background

- Set custom check intervals for each account. From every minute to once a day, - you control the frequency. + Checks your old inboxes on a schedule you choose — from every minute + to once a day. New mail appears in Gmail as if it was always there.

@@ -127,11 +194,11 @@ export default function Home() {

- Secure & Private + Your passwords stay yours

- Your credentials are encrypted and secure. We use SSL/TLS for all connections - and OAuth2 for Gmail. + Credentials are encrypted at rest and never shared. All connections + use SSL/TLS and Gmail delivery uses OAuth2 — no app passwords needed.

@@ -139,53 +206,71 @@ export default function Home() { {/* How It Works */}

- How It Works + Three steps and you're done

1
-

- Connect Accounts -

+

Add your old inbox

- Add your POP3 email accounts with auto-detected settings + Paste the email address — InboxRescue auto-detects the POP3/IMAP + settings in seconds.

-
2
-

- Authorize Gmail -

+

Connect Gmail

- Sign in with Google to allow forwarding to your Gmail + Sign in with Google once. InboxRescue delivers mail directly into + your inbox using the Gmail API — no SMTP relay needed.

-
3
-

- Relax & Enjoy -

+

Close the tab

- Emails are automatically forwarded. Monitor activity from your dashboard + Seriously, that's it. Your mail arrives automatically from now on. + Check the dashboard whenever you like, but you won't need to.

+ + {/* Pricing — only rendered when paid plans exist (hidden in enterprise/all-free mode) */} + {hasPaidPlans && ( +
+

+ Pricing +

+

+ Start free. Upgrade if you need more inboxes or faster checks. + Cancel any time — no questions, no fuss. +

+
+ {plans.map((plan) => ( + + ))} +
+

+ All paid plans include a{' '} + free tier when you first sign up + — no credit card required. +

+
+ )} {/* Footer */}
-

- © 2024 POP3 Forwarder. Secure email forwarding service. +

+ © {new Date().getFullYear()} InboxRescue — made for people, not enterprises.

diff --git a/frontend/src/components/DashboardLayout.tsx b/frontend/src/components/DashboardLayout.tsx index 9c92656..1585e16 100644 --- a/frontend/src/components/DashboardLayout.tsx +++ b/frontend/src/components/DashboardLayout.tsx @@ -54,7 +54,7 @@ export function DashboardLayout({ children }: DashboardLayoutProps) {
-

POP3 Forwarder

+

InboxRescue