B2C/B2B split: DEFAULT_USER_TIER, ALLOWED_DOMAINS, zero-price filter, InboxRescue branding & pricing page

Agent-Logs-Url: https://github.com/christianlouis/pop_puller_to_gmail/sessions/98c1f90b-0287-4908-a0be-ad066c453cc5

Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
This commit is contained in:
copilot-swe-agent[bot]
2026-03-26 16:10:39 +00:00
parent 53334402aa
commit d4aa353383
8 changed files with 237 additions and 70 deletions
+8 -3
View File
@@ -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.
+62 -20
View File
@@ -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)
+11 -2
View File
@@ -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()
+26
View File
@@ -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:
+2 -2
View File
@@ -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",
}
+2 -2
View File
@@ -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({
+124 -39
View File
@@ -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<SubscriptionPlan[]> {
const res = await api.get<SubscriptionPlan[]>('/subscriptions/plans');
return res.data;
}
function PricingCard({ plan }: { plan: SubscriptionPlan }) {
const yearlyMonthly = plan.price_yearly
? (plan.price_yearly / 12).toFixed(2)
: null;
return (
<div className="bg-white rounded-xl shadow-md p-8 flex flex-col border border-gray-100 hover:shadow-lg transition-shadow">
<h3 className="text-xl font-bold text-gray-900">{plan.name}</h3>
<p className="mt-2 text-sm text-gray-500 flex-1">{plan.description}</p>
<div className="mt-6">
<span className="text-4xl font-extrabold text-gray-900">
{plan.price_monthly.toFixed(2)}
</span>
<span className="text-gray-500">/month</span>
{yearlyMonthly && (
<p className="text-xs text-green-600 mt-1">
or {yearlyMonthly}/mo billed yearly ({plan.price_yearly?.toFixed(2)})
</p>
)}
</div>
<ul className="mt-6 space-y-2 text-sm text-gray-600">
<li className="flex items-center gap-2">
<Check className="h-4 w-4 text-green-500 shrink-0" />
{plan.max_mail_accounts === 1
? '1 mailbox'
: `Up to ${plan.max_mail_accounts} mailboxes`}
</li>
<li className="flex items-center gap-2">
<Check className="h-4 w-4 text-green-500 shrink-0" />
Checked every {plan.check_interval_minutes} minute{plan.check_interval_minutes > 1 ? 's' : ''}
</li>
<li className="flex items-center gap-2">
<Check className="h-4 w-4 text-green-500 shrink-0" />
Up to {plan.max_emails_per_day.toLocaleString()} emails/day
</li>
</ul>
<Link
href="/register"
className="mt-8 block text-center px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors font-medium"
>
Get started
</Link>
</div>
);
}
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 (
<div className="min-h-screen flex items-center justify-center">
@@ -37,6 +96,8 @@ export default function Home() {
);
}
const hasPaidPlans = plans && plans.length > 0;
return (
<div className="min-h-screen bg-gradient-to-b from-blue-50 to-white">
{/* Header */}
@@ -45,7 +106,7 @@ export default function Home() {
<div className="flex justify-between items-center py-4">
<div className="flex items-center">
<Mail className="h-8 w-8 text-blue-600 mr-2" />
<h1 className="text-2xl font-bold text-gray-900">POP3 Forwarder</h1>
<h1 className="text-2xl font-bold text-gray-900">InboxRescue</h1>
</div>
<div className="flex items-center gap-4">
<Link
@@ -58,31 +119,37 @@ export default function Home() {
href="/register"
className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 transition-colors"
>
Sign Up
Sign Up Free
</Link>
</div>
</div>
</div>
</header>
{/* Hero Section */}
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-16">
{/* Hero */}
<div className="text-center">
<h2 className="text-4xl sm:text-5xl font-bold text-gray-900 mb-6">
Forward Your POP3 Emails to Gmail
<br />
<span className="text-blue-600">Automatically</span>
Your old inboxes,{' '}
<span className="text-blue-600">delivered to Gmail.</span>
</h2>
<p className="text-xl text-gray-600 mb-8 max-w-2xl mx-auto">
Connect your POP3 email accounts and automatically forward all messages to Gmail.
Simple, secure, and reliable email forwarding service.
<p className="text-xl text-gray-600 mb-4 max-w-2xl mx-auto">
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.
</p>
<div className="flex items-center justify-center gap-4">
<p className="text-base text-gray-500 mb-8 max-w-xl mx-auto">
No forwarding rules to configure. No email clients to keep open.
Just your mail, where you actually read it.
</p>
<div className="flex items-center justify-center gap-4 flex-wrap">
<Link
href="/register"
className="flex items-center px-6 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors text-lg font-medium"
>
Get Started Free
Get Started it&apos;s free
<ArrowRight className="ml-2 h-5 w-5" />
</Link>
<Link
@@ -101,11 +168,11 @@ export default function Home() {
<Zap className="h-6 w-6" />
</div>
<h3 className="text-xl font-semibold text-gray-900 mb-2">
Auto-Detection
Auto-detects everything
</h3>
<p className="text-gray-600">
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.
</p>
</div>
@@ -114,11 +181,11 @@ export default function Home() {
<Clock className="h-6 w-6" />
</div>
<h3 className="text-xl font-semibold text-gray-900 mb-2">
Scheduled Checks
Runs in the background
</h3>
<p className="text-gray-600">
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.
</p>
</div>
@@ -127,11 +194,11 @@ export default function Home() {
<Shield className="h-6 w-6" />
</div>
<h3 className="text-xl font-semibold text-gray-900 mb-2">
Secure & Private
Your passwords stay yours
</h3>
<p className="text-gray-600">
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.
</p>
</div>
</div>
@@ -139,53 +206,71 @@ export default function Home() {
{/* How It Works */}
<div className="mt-20">
<h3 className="text-3xl font-bold text-center text-gray-900 mb-12">
How It Works
Three steps and you&apos;re done
</h3>
<div className="grid md:grid-cols-3 gap-8">
<div className="text-center">
<div className="flex items-center justify-center h-16 w-16 rounded-full bg-blue-100 text-blue-600 text-2xl font-bold mx-auto mb-4">
1
</div>
<h4 className="text-xl font-semibold text-gray-900 mb-2">
Connect Accounts
</h4>
<h4 className="text-xl font-semibold text-gray-900 mb-2">Add your old inbox</h4>
<p className="text-gray-600">
Add your POP3 email accounts with auto-detected settings
Paste the email address InboxRescue auto-detects the POP3/IMAP
settings in seconds.
</p>
</div>
<div className="text-center">
<div className="flex items-center justify-center h-16 w-16 rounded-full bg-blue-100 text-blue-600 text-2xl font-bold mx-auto mb-4">
2
</div>
<h4 className="text-xl font-semibold text-gray-900 mb-2">
Authorize Gmail
</h4>
<h4 className="text-xl font-semibold text-gray-900 mb-2">Connect Gmail</h4>
<p className="text-gray-600">
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.
</p>
</div>
<div className="text-center">
<div className="flex items-center justify-center h-16 w-16 rounded-full bg-blue-100 text-blue-600 text-2xl font-bold mx-auto mb-4">
3
</div>
<h4 className="text-xl font-semibold text-gray-900 mb-2">
Relax & Enjoy
</h4>
<h4 className="text-xl font-semibold text-gray-900 mb-2">Close the tab</h4>
<p className="text-gray-600">
Emails are automatically forwarded. Monitor activity from your dashboard
Seriously, that&apos;s it. Your mail arrives automatically from now on.
Check the dashboard whenever you like, but you won&apos;t need to.
</p>
</div>
</div>
</div>
{/* Pricing — only rendered when paid plans exist (hidden in enterprise/all-free mode) */}
{hasPaidPlans && (
<div className="mt-24">
<h3 className="text-3xl font-bold text-center text-gray-900 mb-4">
Pricing
</h3>
<p className="text-center text-gray-500 mb-12 max-w-xl mx-auto">
Start free. Upgrade if you need more inboxes or faster checks.
Cancel any time no questions, no fuss.
</p>
<div className={`grid gap-8 ${plans.length === 1 ? 'max-w-sm mx-auto' : plans.length === 2 ? 'md:grid-cols-2 max-w-2xl mx-auto' : 'md:grid-cols-3'}`}>
{plans.map((plan) => (
<PricingCard key={plan.id} plan={plan} />
))}
</div>
<p className="mt-8 text-center text-sm text-gray-500">
All paid plans include a{' '}
<span className="font-medium">free tier</span> when you first sign up
no credit card required.
</p>
</div>
)}
</main>
{/* Footer */}
<footer className="mt-20 border-t border-gray-200 bg-white">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<p className="text-center text-gray-600">
© 2024 POP3 Forwarder. Secure email forwarding service.
<p className="text-center text-gray-600 text-sm">
© {new Date().getFullYear()} InboxRescue made for people, not enterprises.
</p>
</div>
</footer>
+2 -2
View File
@@ -54,7 +54,7 @@ export function DashboardLayout({ children }: DashboardLayoutProps) {
<div className="hidden lg:fixed lg:inset-y-0 lg:flex lg:w-64 lg:flex-col">
<div className="flex flex-col flex-grow bg-white border-r border-gray-200">
<div className="flex items-center h-16 flex-shrink-0 px-4 border-b border-gray-200">
<h1 className="text-xl font-bold text-gray-900">POP3 Forwarder</h1>
<h1 className="text-xl font-bold text-gray-900">InboxRescue</h1>
</div>
<nav className="flex-1 px-2 py-4 space-y-1">
{navigation.map((item) => {
@@ -117,7 +117,7 @@ export function DashboardLayout({ children }: DashboardLayoutProps) {
<div className="fixed inset-0 bg-gray-600/75" onClick={() => setSidebarOpen(false)} />
<div className="fixed inset-y-0 left-0 flex w-64 flex-col bg-white">
<div className="flex items-center justify-between h-16 px-4 border-b border-gray-200">
<h1 className="text-xl font-bold text-gray-900">POP3 Forwarder</h1>
<h1 className="text-xl font-bold text-gray-900">InboxRescue</h1>
<button onClick={() => setSidebarOpen(false)} className="text-gray-500 hover:text-gray-700">
<X className="h-6 w-6" />
</button>