File Storage and Email: Cloudflare R2 for Vendor Documents and Resend for Transactional Email Without an SMTP Server
File Storage and Email
Two features that every SaaS needs and no developer should build from scratch: file storage and transactional email. Vendor documents (insurance certificates, health permits, product photos) need to be uploaded, stored, and retrieved securely. Emails (application confirmations, status notifications, payment receipts) need to be delivered reliably without managing an SMTP server.
Cloudflare R2 handles file storage. It is S3-compatible, has no egress fees, and provides 10 GB of free storage. Resend handles email delivery. It is API-based, supports React email templates, and provides 100 free emails per day.
The Feature
A vendor uploads their insurance certificate and product photos during the application process. The files are stored in Cloudflare R2 and accessible to the market organizer. When the organizer accepts or rejects an application, the vendor receives an email notification. When a new vendor applies, the organizer receives an email.
The Decision
Cloudflare R2 over S3 or self-hosted storage. R2 has no egress fees. S3 charges for every byte downloaded. For a SaaS that stores vendor documents and serves them to organizers, egress costs can surprise you. R2’s free tier provides 10 GB of storage, 10 million Class B operations (reads), and 1 million Class A operations (writes) per month. A vendor document averages 200 KB. 10 GB stores approximately 50,000 documents.
Resend over SendGrid, Mailgun, or self-hosted SMTP. Resend is API-first. No SMTP configuration. No IP warmup. No deliverability reputation management. Send an HTTP request, the email is delivered. The free tier provides 100 emails per day, which handles Marketflow’s transactional email volume at launch.
The Implementation
Cloudflare R2 Setup
# backend/app/services/storage.py
import uuid
import boto3
from botocore.config import Config
from app.config import settings
class StorageService:
def __init__(self) -> None:
self.s3 = boto3.client(
"s3",
endpoint_url=settings.r2_endpoint_url,
aws_access_key_id=settings.r2_access_key_id,
aws_secret_access_key=settings.r2_secret_access_key,
config=Config(signature_version="s3v4"),
region_name="auto",
)
self.bucket = settings.r2_bucket_name
def generate_upload_url(
self,
market_id: uuid.UUID,
vendor_id: uuid.UUID,
filename: str,
) -> dict[str, str]:
"""Generate a pre-signed URL for direct browser upload."""
# Sanitize filename
safe_filename = filename.replace("/", "_").replace("\\", "_")
key = f"vendors/{vendor_id}/{market_id}/{uuid.uuid4()}_{safe_filename}"
url = self.s3.generate_presigned_url(
"put_object",
Params={
"Bucket": self.bucket,
"Key": key,
"ContentType": "application/octet-stream",
},
ExpiresIn=3600, # 1 hour
)
return {"upload_url": url, "file_key": key}
def generate_download_url(self, file_key: str) -> str:
"""Generate a pre-signed URL for downloading a file."""
return self.s3.generate_presigned_url(
"get_object",
Params={
"Bucket": self.bucket,
"Key": file_key,
},
ExpiresIn=3600,
)
def delete_file(self, file_key: str) -> None:
self.s3.delete_object(Bucket=self.bucket, Key=file_key)
File Upload Endpoint
# backend/app/routers/files.py
import uuid
from fastapi import APIRouter, Depends
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.dependencies import get_market_for_organizer, get_vendor_profile
from app.models.market import Market
from app.models.vendor import Vendor
from app.services.storage import StorageService
router = APIRouter(prefix="/api/files", tags=["files"])
storage = StorageService()
@router.post("/upload-url")
async def get_upload_url(
market_id: uuid.UUID,
filename: str,
vendor: Vendor = Depends(get_vendor_profile),
) -> dict[str, str]:
"""Get a pre-signed URL for uploading a vendor document."""
return storage.generate_upload_url(
market_id=market_id,
vendor_id=vendor.id,
filename=filename,
)
@router.get("/download-url")
async def get_download_url(
file_key: str,
market: Market = Depends(get_market_for_organizer),
) -> dict[str, str]:
"""Get a pre-signed URL for downloading a vendor document.
Only market organizers can download vendor documents."""
# Verify the file belongs to this market
if str(market.id) not in file_key:
raise HTTPException(status_code=403, detail="Access denied")
return {"download_url": storage.generate_download_url(file_key)}
Frontend File Upload Component
// frontend/src/components/FileUpload.tsx
import { useState, useCallback } from "react";
import { Button } from "@/components/ui/button";
import { useGetUploadUrl } from "@/api/generated";
interface FileUploadProps {
marketId: string;
onUploadComplete: (fileKey: string, filename: string) => void;
}
export function FileUpload({ marketId, onUploadComplete }: FileUploadProps) {
const [uploading, setUploading] = useState(false);
const getUploadUrl = useGetUploadUrl();
const handleFileSelect = useCallback(
async (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (!file) return;
// Validate file size (max 10 MB)
if (file.size > 10 * 1024 * 1024) {
alert("File must be under 10 MB");
return;
}
setUploading(true);
try {
// Get pre-signed upload URL
const { upload_url, file_key } = await getUploadUrl.mutateAsync({
params: { market_id: marketId, filename: file.name },
});
// Upload directly to R2
await fetch(upload_url, {
method: "PUT",
body: file,
headers: { "Content-Type": file.type },
});
onUploadComplete(file_key, file.name);
} catch {
alert("Upload failed. Please try again.");
} finally {
setUploading(false);
}
},
[marketId, getUploadUrl, onUploadComplete]
);
return (
<div>
<input
type="file"
accept=".pdf,.jpg,.jpeg,.png"
onChange={handleFileSelect}
disabled={uploading}
className="hidden"
id="file-upload"
/>
<Button asChild variant="outline" disabled={uploading}>
<label htmlFor="file-upload" className="cursor-pointer">
{uploading ? "Uploading..." : "Upload Document"}
</label>
</Button>
</div>
);
}
The file upload flow uses pre-signed URLs. The browser uploads directly to Cloudflare R2, not through the FastAPI backend. This means large files do not consume backend memory or bandwidth. The backend only generates the URL (a lightweight operation) and records the file key in the database.
Resend Email Service
# backend/app/services/email.py
import resend
from app.config import settings
resend.api_key = settings.resend_api_key
async def send_application_accepted(
vendor_email: str,
vendor_name: str,
market_name: str,
) -> None:
resend.Emails.send({
"from": "Marketflow <[email protected]>",
"to": vendor_email,
"subject": f"Your application to {market_name} has been accepted",
"html": f"""
<h2>Congratulations, {vendor_name}!</h2>
<p>Your application to <strong>{market_name}</strong> has been accepted.</p>
<p>Log in to Marketflow to view your stall assignment and upcoming market days.</p>
<p><a href="https://marketflow.app/vendor">Go to Vendor Portal</a></p>
""",
})
async def send_application_rejected(
vendor_email: str,
vendor_name: str,
market_name: str,
) -> None:
resend.Emails.send({
"from": "Marketflow <[email protected]>",
"to": vendor_email,
"subject": f"Update on your application to {market_name}",
"html": f"""
<h2>Hi {vendor_name},</h2>
<p>Thank you for your interest in <strong>{market_name}</strong>.</p>
<p>Unfortunately, we are unable to accept your application at this time.
You are welcome to apply again for future market seasons.</p>
""",
})
async def send_new_application_notification(
organizer_email: str,
vendor_name: str,
market_name: str,
) -> None:
resend.Emails.send({
"from": "Marketflow <[email protected]>",
"to": organizer_email,
"subject": f"New vendor application for {market_name}",
"html": f"""
<h2>New Application</h2>
<p><strong>{vendor_name}</strong> has applied to join <strong>{market_name}</strong>.</p>
<p><a href="https://marketflow.app/dashboard/applications">Review Applications</a></p>
""",
})
Domain Verification for Email
Resend requires domain verification to send from @marketflow.app:
- Add the domain in Resend dashboard
- Add the DNS records (DKIM, SPF, DMARC) in Cloudflare
- Wait for verification (typically 5-10 minutes with Cloudflare)
# Cloudflare DNS records for email
Type Name Content
TXT @ v=spf1 include:_spf.resend.com ~all
CNAME resend._domainkey resend._domainkey.marketflow.app.resend.com
TXT _dmarc v=DMARC1; p=quarantine; rua=mailto:[email protected]
The Trap
# TRAP: Accepting file uploads through the backend
@router.post("/upload")
async def upload_file(file: UploadFile):
contents = await file.read() # Entire file in memory
storage.upload(contents)
# A 50 MB file consumes 50 MB of backend RAM
# 10 concurrent uploads = 500 MB of RAM
# The $4.51 VPS has 4 GB total
# SAFE: Pre-signed URLs let the browser upload directly to R2
@router.post("/upload-url")
async def get_upload_url(filename: str):
return storage.generate_upload_url(filename)
# Backend generates a URL (0 MB of RAM) and the browser uploads directly
Pre-signed URLs eliminate the backend as a file proxy. The backend never touches the file bytes. This is not just an optimization. It is a resource constraint. A 4 GB VPS cannot buffer large file uploads without risking out-of-memory crashes.
The Cost
| Component | Free Tier | Paid Tier |
|---|---|---|
| Cloudflare R2 | 10 GB storage, 10M reads | $0.015/GB/month above 10 GB |
| Resend | 100 emails/day | $20/month for 50,000/month |
At launch, both free tiers are sufficient. Marketflow’s email volume at 50 customers is approximately 20-50 emails per day (application notifications, status changes). File storage at 50 customers is approximately 500 documents averaging 200 KB each, totaling 100 MB. Both are well within free tier limits.