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