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. - `GET /admin/users/{id}` Get a single user's details.
- `PUT /admin/users/{id}` Update user details, plan, active status, and superuser flag. - `PUT /admin/users/{id}` Update user details, plan, active status, and superuser flag.
- `DELETE /admin/users/{id}` Delete a user. - `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. - `POST /admin/plans` Create a new subscription plan.
- `PUT /admin/plans/{id}` Update a subscription plan. - `PUT /admin/plans/{id}` Update a subscription plan.
- `DELETE /admin/plans/{id}` Delete 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. - **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 ### Fixed
- Test email sender name corrected from "Christian Loris" to "Christian Krakau-Louis". - 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: - **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. 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). 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
- 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. - 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( @router.post(
"/register", response_model=UserResponse, status_code=status.HTTP_201_CREATED "/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" 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 # Create new user
user = User( user = User(
email=user_in.email, email=user_in.email,
@@ -55,12 +100,9 @@ async def register(user_in: UserCreate, db: AsyncSession = Depends(get_db)):
hashed_password=( hashed_password=(
get_password_hash(user_in.password) if user_in.password else None get_password_hash(user_in.password) if user_in.password else None
), ),
subscription_tier=SubscriptionTier.FREE, subscription_tier=_default_tier(),
is_active=True, is_active=True,
is_superuser=( is_superuser=_is_admin_email(user_in.email),
settings.ADMIN_EMAIL is not None
and user_in.email.lower() == settings.ADMIN_EMAIL.lower()
),
) )
db.add(user) db.add(user)
@@ -103,15 +145,15 @@ async def login(
status_code=status.HTTP_403_FORBIDDEN, detail="User account is inactive" 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 # Update last login
user.last_login_at = datetime.now(timezone.utc) # type: ignore[assignment] user.last_login_at = datetime.now(timezone.utc) # type: ignore[assignment]
# Auto-promote to superuser if this is the configured admin email # Auto-promote to superuser if this is the configured admin email
if ( if not user.is_superuser and _is_admin_email(user.email):
settings.ADMIN_EMAIL is not None
and user.email.lower() == settings.ADMIN_EMAIL.lower()
and not user.is_superuser
):
user.is_superuser = True # type: ignore[assignment] user.is_superuser = True # type: ignore[assignment]
logger.info(f"Auto-promoted admin user: {user.email}") logger.info(f"Auto-promoted admin user: {user.email}")
@@ -163,30 +205,30 @@ async def google_oauth(
# Update last login # Update last login
user.last_login_at = datetime.now(timezone.utc) # type: ignore[assignment] 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 # Auto-promote to superuser if this is the configured admin email
if ( if not user.is_superuser and _is_admin_email(email):
settings.ADMIN_EMAIL is not None
and user.email.lower() == settings.ADMIN_EMAIL.lower()
and not user.is_superuser
):
user.is_superuser = True # type: ignore[assignment] user.is_superuser = True # type: ignore[assignment]
logger.info(f"Auto-promoted admin user via Google OAuth: {user.email}") logger.info(f"Auto-promoted admin user via Google OAuth: {user.email}")
logger.info(f"Existing user logged in with Google: {user.email}") logger.info(f"Existing user logged in with Google: {user.email}")
else: else:
# Domain restriction check before creating the account
_check_domain_allowed(email)
# Create new user # Create new user
user = User( user = User(
email=email, email=email,
full_name=user_info.get("full_name"), full_name=user_info.get("full_name"),
google_id=google_id, google_id=google_id,
oauth_provider="google", oauth_provider="google",
subscription_tier=SubscriptionTier.FREE, subscription_tier=_default_tier(),
is_active=True, is_active=True,
last_login_at=datetime.now(timezone.utc), last_login_at=datetime.now(timezone.utc),
is_superuser=( is_superuser=_is_admin_email(email),
settings.ADMIN_EMAIL is not None
and email.lower() == settings.ADMIN_EMAIL.lower()
),
) )
db.add(user) db.add(user)
+11 -2
View File
@@ -15,9 +15,18 @@ router = APIRouter()
@router.get("/plans", response_model=List[SubscriptionPlanResponse]) @router.get("/plans", response_model=List[SubscriptionPlanResponse])
async def list_subscription_plans(db: AsyncSession = Depends(get_db)): 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( 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() return result.scalars().all()
+26
View File
@@ -97,6 +97,15 @@ class Settings(BaseSettings):
ADMIN_EMAIL: Optional[str] = "christianlouis@gmail.com" ADMIN_EMAIL: Optional[str] = "christianlouis@gmail.com"
ADMIN_PASSWORD: Optional[str] = None 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
MAIL_SERVER_PRESETS_FILE: str = "app/data/mail_server_presets.json" 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 [i.strip() for i in v.split(",")]
return v 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") @field_validator("SECRET_KEY")
@classmethod @classmethod
def validate_secret_key(cls, v: str) -> str: def validate_secret_key(cls, v: str) -> str:
+2 -2
View File
@@ -65,7 +65,7 @@ def create_application() -> FastAPI:
app = FastAPI( app = FastAPI(
title=settings.APP_NAME, title=settings.APP_NAME,
version=settings.APP_VERSION, 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", docs_url="/api/docs",
redoc_url="/api/redoc", redoc_url="/api/redoc",
openapi_url="/api/openapi.json", openapi_url="/api/openapi.json",
@@ -92,7 +92,7 @@ def create_application() -> FastAPI:
async def root(): async def root():
"""Root endpoint""" """Root endpoint"""
return { return {
"message": "POP3 Forwarder SaaS API", "message": "InboxRescue API",
"version": settings.APP_VERSION, "version": settings.APP_VERSION,
"docs": "/api/docs", "docs": "/api/docs",
} }
+2 -2
View File
@@ -14,8 +14,8 @@ const geistMono = Geist_Mono({
}); });
export const metadata: Metadata = { export const metadata: Metadata = {
title: "POP3 Forwarder - Automatic Email Forwarding to Gmail", title: "InboxRescue — your old inboxes, delivered to Gmail",
description: "Forward your POP3 emails to Gmail automatically with our secure and reliable service", 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({ export default function RootLayout({
+124 -39
View File
@@ -4,8 +4,61 @@ import { useEffect } from 'react';
import Link from 'next/link'; import Link from 'next/link';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { useAuthStore } from '@/store/authStore'; import { useAuthStore } from '@/store/authStore';
import { userApi } from '@/lib/api'; import { userApi, SubscriptionPlan } from '@/lib/api';
import { Mail, ArrowRight, Shield, Zap, Clock } from 'lucide-react'; 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() { export default function Home() {
const router = useRouter(); const router = useRouter();
@@ -29,6 +82,12 @@ export default function Home() {
}); });
}, [router, setUser, setLoading]); }, [router, setUser, setLoading]);
const { data: plans } = useQuery({
queryKey: ['public-plans'],
queryFn: fetchPublicPlans,
staleTime: 5 * 60 * 1000,
});
if (isLoading) { if (isLoading) {
return ( return (
<div className="min-h-screen flex items-center justify-center"> <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 ( return (
<div className="min-h-screen bg-gradient-to-b from-blue-50 to-white"> <div className="min-h-screen bg-gradient-to-b from-blue-50 to-white">
{/* Header */} {/* Header */}
@@ -45,7 +106,7 @@ export default function Home() {
<div className="flex justify-between items-center py-4"> <div className="flex justify-between items-center py-4">
<div className="flex items-center"> <div className="flex items-center">
<Mail className="h-8 w-8 text-blue-600 mr-2" /> <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>
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<Link <Link
@@ -58,31 +119,37 @@ export default function Home() {
href="/register" href="/register"
className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 transition-colors" className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 transition-colors"
> >
Sign Up Sign Up Free
</Link> </Link>
</div> </div>
</div> </div>
</div> </div>
</header> </header>
{/* Hero Section */}
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-16"> <main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-16">
{/* Hero */}
<div className="text-center"> <div className="text-center">
<h2 className="text-4xl sm:text-5xl font-bold text-gray-900 mb-6"> <h2 className="text-4xl sm:text-5xl font-bold text-gray-900 mb-6">
Forward Your POP3 Emails to Gmail Your old inboxes,{' '}
<br /> <span className="text-blue-600">delivered to Gmail.</span>
<span className="text-blue-600">Automatically</span>
</h2> </h2>
<p className="text-xl text-gray-600 mb-8 max-w-2xl mx-auto"> <p className="text-xl text-gray-600 mb-4 max-w-2xl mx-auto">
Connect your POP3 email accounts and automatically forward all messages to Gmail. You know the ones that GMX account from 2009, the old ISP address your
Simple, secure, and reliable email forwarding service. 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> </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 <Link
href="/register" 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" 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" /> <ArrowRight className="ml-2 h-5 w-5" />
</Link> </Link>
<Link <Link
@@ -101,11 +168,11 @@ export default function Home() {
<Zap className="h-6 w-6" /> <Zap className="h-6 w-6" />
</div> </div>
<h3 className="text-xl font-semibold text-gray-900 mb-2"> <h3 className="text-xl font-semibold text-gray-900 mb-2">
Auto-Detection Auto-detects everything
</h3> </h3>
<p className="text-gray-600"> <p className="text-gray-600">
Automatically detect POP3 server settings from your email address. Type your old email address and InboxRescue figures out the server
Quick and easy setup in minutes. settings. No Googling port numbers required.
</p> </p>
</div> </div>
@@ -114,11 +181,11 @@ export default function Home() {
<Clock className="h-6 w-6" /> <Clock className="h-6 w-6" />
</div> </div>
<h3 className="text-xl font-semibold text-gray-900 mb-2"> <h3 className="text-xl font-semibold text-gray-900 mb-2">
Scheduled Checks Runs in the background
</h3> </h3>
<p className="text-gray-600"> <p className="text-gray-600">
Set custom check intervals for each account. From every minute to once a day, Checks your old inboxes on a schedule you choose from every minute
you control the frequency. to once a day. New mail appears in Gmail as if it was always there.
</p> </p>
</div> </div>
@@ -127,11 +194,11 @@ export default function Home() {
<Shield className="h-6 w-6" /> <Shield className="h-6 w-6" />
</div> </div>
<h3 className="text-xl font-semibold text-gray-900 mb-2"> <h3 className="text-xl font-semibold text-gray-900 mb-2">
Secure & Private Your passwords stay yours
</h3> </h3>
<p className="text-gray-600"> <p className="text-gray-600">
Your credentials are encrypted and secure. We use SSL/TLS for all connections Credentials are encrypted at rest and never shared. All connections
and OAuth2 for Gmail. use SSL/TLS and Gmail delivery uses OAuth2 no app passwords needed.
</p> </p>
</div> </div>
</div> </div>
@@ -139,53 +206,71 @@ export default function Home() {
{/* How It Works */} {/* How It Works */}
<div className="mt-20"> <div className="mt-20">
<h3 className="text-3xl font-bold text-center text-gray-900 mb-12"> <h3 className="text-3xl font-bold text-center text-gray-900 mb-12">
How It Works Three steps and you&apos;re done
</h3> </h3>
<div className="grid md:grid-cols-3 gap-8"> <div className="grid md:grid-cols-3 gap-8">
<div className="text-center"> <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"> <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 1
</div> </div>
<h4 className="text-xl font-semibold text-gray-900 mb-2"> <h4 className="text-xl font-semibold text-gray-900 mb-2">Add your old inbox</h4>
Connect Accounts
</h4>
<p className="text-gray-600"> <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> </p>
</div> </div>
<div className="text-center"> <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"> <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 2
</div> </div>
<h4 className="text-xl font-semibold text-gray-900 mb-2"> <h4 className="text-xl font-semibold text-gray-900 mb-2">Connect Gmail</h4>
Authorize Gmail
</h4>
<p className="text-gray-600"> <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> </p>
</div> </div>
<div className="text-center"> <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"> <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 3
</div> </div>
<h4 className="text-xl font-semibold text-gray-900 mb-2"> <h4 className="text-xl font-semibold text-gray-900 mb-2">Close the tab</h4>
Relax & Enjoy
</h4>
<p className="text-gray-600"> <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> </p>
</div> </div>
</div> </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> </main>
{/* Footer */} {/* Footer */}
<footer className="mt-20 border-t border-gray-200 bg-white"> <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"> <div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<p className="text-center text-gray-600"> <p className="text-center text-gray-600 text-sm">
© 2024 POP3 Forwarder. Secure email forwarding service. © {new Date().getFullYear()} InboxRescue made for people, not enterprises.
</p> </p>
</div> </div>
</footer> </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="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 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"> <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> </div>
<nav className="flex-1 px-2 py-4 space-y-1"> <nav className="flex-1 px-2 py-4 space-y-1">
{navigation.map((item) => { {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-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="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"> <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"> <button onClick={() => setSidebarOpen(false)} className="text-gray-500 hover:text-gray-700">
<X className="h-6 w-6" /> <X className="h-6 w-6" />
</button> </button>