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:
+8
-3
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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
@@ -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",
|
||||
}
|
||||
|
||||
@@ -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
@@ -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'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'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's it. Your mail arrives automatically from now on.
|
||||
Check the dashboard whenever you like, but you won'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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user