Skip to main content
ship before you scale

File Storage and Email: Cloudflare R2 for Vendor Documents and Resend for Transactional Email Without an SMTP Server

7 min read Chapter 28 of 42

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:

  1. Add the domain in Resend dashboard
  2. Add the DNS records (DKIM, SPF, DMARC) in Cloudflare
  3. 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

ComponentFree TierPaid Tier
Cloudflare R210 GB storage, 10M reads$0.015/GB/month above 10 GB
Resend100 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.