Skip to main content

max / makenotwork

Transactional safety, account deletion, admin CLI, input validation, and tests - Refactor DB functions to accept generic executors for transaction support - Wrap multi-step operations (checkout, webhooks, version creation) in transactions - Fix Stripe Connect race condition with atomic try_set_stripe_account - Prevent users from purchasing their own items - Implement two-step account deletion with confirmation page - Atomic login token consumption replacing get+mark pattern - Block OAuth authorization for 2FA-enabled users - Add input validation for item/chapter/link update endpoints - Expand admin CLI: suspend/unsuspend, appeals, revenue stats, CSV export - Refactor users API from single file to module directory - Add integration tests for chapters, contacts, versions, waitlist - Add creator guide documentation and update roadmap - Remove unused functions, add ON DELETE CASCADE to contact revocations Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Author: Max J. <87768334+MaxJMath@users.noreply.github.com> · 2026-03-09 16:59 UTC
Commit: a58cc29235ee3f6b0b44d37c9fb02c5a4ab80fbd
Parent: adc7f41
56 files changed, +3361 insertions, -988 deletions
@@ -72,10 +72,12 @@ Everything listed here is live and working.
72 72
73 73 - **Source-available codebase**: PolyForm Noncommercial 1.0.0
74 74 - **Creator waitlist**: Invite-only launch with lottery waves and hand-picked approvals
75 - - **Admin CLI** (`mnw-admin`): Command-line tool for waitlist management, creator approval, spam flagging, wave execution, and stats -- connects directly to the database, no web UI needed
75 + - **Admin CLI** (`mnw-admin`): Command-line tool for waitlist management, creator approval, spam flagging, wave execution, stats, user suspension/unsuspension, appeal processing, revenue reports, transaction history, CSV data export, and S3 storage audits -- connects directly to the database, no web UI needed
76 76 - **Documentation**: Server-rendered from markdown, auto-linked cross-references
77 77 - **Health monitoring**: Real uptime tracking, database status, service connectivity checks
78 - - **386 automated tests**: Unit, integration, workflow, and health tests
78 + - **Malware scanning**: ClamAV + VirusTotal hash lookup on file uploads
79 + - **Creator guide**: 12-page documentation covering the full UX surface area
80 + - **619 automated tests**: Unit, integration, workflow, and health tests
79 81
80 82 ### Developer Infrastructure (SyncKit)
81 83
@@ -98,8 +100,7 @@ Near-term work. No timelines because we ship when it's ready.
98 100 - **Free trial support** for subscription tiers
99 101 - **Sale and follower notifications** (email alerts for creators)
100 102 - **Contacts dashboard** (view fans who shared their email at purchase)
101 - - **Malware scanning** on upload (ClamAV + VirusTotal hash lookup)
102 - - **Admin CLI expansion**: User suspension/unsuspension, appeal processing, broadcast sending, revenue/transaction reports, data export triggers, storage usage audit
103 + - **Admin CLI expansion**: Broadcast sending (deferred until Postmark integration)
103 104
104 105 ---
105 106
@@ -0,0 +1,91 @@
1 + # Getting Started
2 +
3 + Your first 15 minutes on Makenot.work — from sign-up to your first published item.
4 +
5 + ---
6 +
7 + ## Create Your Account
8 +
9 + 1. Visit the homepage and click **Join**
10 + 2. Pick a username (this becomes your public URL: `/u/yourname`)
11 + 3. Enter your email and a strong password
12 + 4. Verify your email (check your inbox)
13 +
14 + Your fan account is ready immediately. You can browse, follow creators, and purchase content right away.
15 +
16 + ## Apply for Creator Access
17 +
18 + Creator access is currently invite-only via the waitlist. To apply:
19 +
20 + 1. Go to the waitlist page
21 + 2. Write a short pitch (20-500 characters) about what you want to create
22 + 3. Submit your application
23 +
24 + Applications are reviewed in waves. You'll get an email when you're approved.
25 +
26 + **Requirements:**
27 + - Verified email address
28 + - You haven't already applied
29 + - You don't already have creator access
30 +
31 + ## Connect Stripe
32 +
33 + Once approved as a creator, connect your Stripe account to receive payments:
34 +
35 + 1. Go to your **Dashboard**
36 + 2. Click **Connect Stripe**
37 + 3. Follow the Stripe Connect onboarding flow
38 + 4. Complete identity verification (Stripe requirement)
39 +
40 + Payments go directly to your Stripe account. We never hold or touch your revenue.
41 +
42 + ## Create Your First Project
43 +
44 + Projects are how you organize your work. Think of them like albums, podcast feeds, or product lines.
45 +
46 + 1. From your Dashboard, click **New Project**
47 + 2. Enter a **slug** (URL-safe name, e.g., `my-album`) and a **title**
48 + 3. Choose a category (Music, Podcast, Software, Blog, Art, etc.)
49 + 4. Add a description
50 +
51 + Your project starts as a draft. You'll make it public after adding items.
52 +
53 + ## Create Your First Item
54 +
55 + Items are individual pieces of content inside a project.
56 +
57 + 1. Navigate to your project
58 + 2. Click **New Item**
59 + 3. Choose an item type:
60 +
61 + | Type | Best For |
62 + |------|----------|
63 + | **Audio** | Songs, podcast episodes, sound effects |
64 + | **Text** | Articles, stories, documentation |
65 + | **Digital** | Software, plugins, files, images |
66 +
67 + 4. Set a title and price (free, fixed, or pay-what-you-want)
68 + 5. Upload your content or write your text
69 +
70 + ## Publish
71 +
72 + 1. Make your item public: edit the item and toggle **is_public**
73 + 2. Make your project public: edit the project and set **visibility** to public
74 + 3. Share your link: `/u/yourname`
75 +
76 + ## First 15 Minutes Checklist
77 +
78 + - [ ] Account created and email verified
79 + - [ ] Waitlist application submitted (or creator access granted)
80 + - [ ] Stripe connected
81 + - [ ] First project created with title, slug, and category
82 + - [ ] First item created with content uploaded
83 + - [ ] Item and project published
84 +
85 + ---
86 +
87 + ## See Also
88 +
89 + - [Projects](./02-projects.md) — Organizing your work
90 + - [Items](./03-items.md) — Content types and settings
91 + - [Pricing & Monetization](./07-pricing.md) — Setting prices and getting paid
@@ -0,0 +1,65 @@
1 + # Projects
2 +
3 + Projects group your items under a single page with its own URL, settings, and feed.
4 +
5 + ---
6 +
7 + ## Creating a Project
8 +
9 + From your Dashboard, click **New Project**. You'll need:
10 +
11 + - **Slug**: URL-safe name (e.g., `my-album`). This becomes `/p/my-album`. Cannot be changed after creation.
12 + - **Title**: Display name shown on the project page.
13 +
14 + ## Project Settings
15 +
16 + Edit your project to configure:
17 +
18 + - **Description**: Text shown on the project page
19 + - **Category**: Helps fans discover your work. Choose from 12 built-in categories (Music, Band, Podcast, Blog, Software, Art, etc.) or create your own
20 + - **Visibility**: Draft (only you can see it) or Public (visible to everyone)
21 +
22 + ## Categories
23 +
24 + Categories are used for discovery and filtering. Each project can have one category. Built-in options include:
25 +
26 + | Category | Typical Use |
27 + |----------|-------------|
28 + | Music | Albums, singles, EPs |
29 + | Band | Band or artist pages |
30 + | Podcast | Podcast feeds |
31 + | Blog | Writing and newsletters |
32 + | Software | Apps, plugins, tools |
33 + | Art | Visual art, photography |
34 + | Education | Courses, tutorials |
35 + | Games | Game projects |
36 +
37 + ## Visibility
38 +
39 + Projects start as **drafts**. Draft projects and their items are invisible to everyone except you.
40 +
41 + To publish, set visibility to `public`. All published items within the project become discoverable.
42 +
43 + ## Organizing Items
44 +
45 + Items are ordered within a project. You can reorder them from the project dashboard. Each item belongs to exactly one project.
46 +
47 + ## Blog
48 +
49 + Every project gets a blog. Blog posts use markdown, support drafts, and are included in the project's RSS feed. See [Content Types](./04-content.md) for details.
50 +
51 + ## RSS Feeds
52 +
53 + Each project automatically generates an RSS feed containing published items and blog posts. Fans can subscribe using any feed reader.
54 +
55 + ## Deleting a Project
56 +
57 + Deleting a project removes it and all its items permanently. This cannot be undone. Active subscriptions should be canceled first.
58 +
59 + ---
60 +
61 + ## See Also
62 +
63 + - [Items](./03-items.md) — Creating content within projects
64 + - [Content Types](./04-content.md) — Audio, text, digital, and blog
65 + - [Analytics & Data](./09-analytics.md) — Project-level stats
@@ -0,0 +1,66 @@
1 + # Items
2 +
3 + Items are individual pieces of content — a song, an article, a software release, a file download.
4 +
5 + ---
6 +
7 + ## Item Types
8 +
9 + | Type | Content | Player/Viewer | Chapters | Versions |
10 + |------|---------|--------------|----------|----------|
11 + | **Audio** | MP3, WAV, FLAC, OGG | In-browser streaming player | Yes | Yes |
12 + | **Text** | Markdown | Clean reading view | No | No |
13 + | **Digital** | Any file (ZIP, DMG, EXE, etc.) | Download link | No | Yes |
14 +
15 + Choose the type when creating the item. It cannot be changed afterward.
16 +
17 + ## Creating an Item
18 +
19 + 1. Navigate to your project
20 + 2. Click **New Item**
21 + 3. Set a **title** and **item type**
22 + 4. Optionally set a price (defaults to free)
23 +
24 + ## Editing an Item
25 +
26 + From the item settings, you can update:
27 +
28 + - **Title**: Display name
29 + - **Description**: Shown on the item page
30 + - **Price**: Free, fixed amount, or pay-what-you-want (see [Pricing](./07-pricing.md))
31 + - **Tags**: Hierarchical tags for discovery
32 + - **Cover image**: Displayed on the item card
33 +
34 + ## Publishing
35 +
36 + Items start as drafts. To publish:
37 +
38 + 1. Edit the item and set **is_public** to true
39 + 2. Make sure the parent project is also public
40 +
41 + Published items appear on your profile, in search results, and in RSS feeds.
42 +
43 + ## Scheduling
44 +
45 + Set a future publish date to schedule content releases. The item becomes visible automatically at the scheduled time.
46 +
47 + ## Bulk Operations
48 +
49 + From the project dashboard, you can perform bulk operations on items: publish, unpublish, or delete multiple items at once.
50 +
51 + ## Duplicating an Item
52 +
53 + Duplicate an item to create a copy with the same settings and metadata. Useful for creating similar items quickly. Content (files, text) is not duplicated — only metadata.
54 +
55 + ## Deleting an Item
56 +
57 + Deleting an item removes it permanently. Fans who purchased it will lose access. Active download codes and license keys for the item are invalidated.
58 +
59 + ---
60 +
61 + ## See Also
62 +
63 + - [Content Types](./04-content.md) — Uploading and managing content
64 + - [Versions](./05-versions.md) — Release versioned files
65 + - [Chapters](./06-chapters.md) — Audio chapter markers
66 + - [Pricing & Monetization](./07-pricing.md) — Free, fixed, and PWYW
@@ -0,0 +1,55 @@
1 + # Content Types
2 +
3 + What you can upload and how it's delivered to fans.
4 +
5 + ---
6 +
7 + ## Audio
8 +
9 + Upload audio files in MP3, WAV, FLAC, or OGG format. Each audio item gets:
10 +
11 + - **In-browser player**: Stream without downloading. Custom player with playback controls.
12 + - **Cover image**: Album art displayed in the player and on the item card.
13 + - **Chapters**: Timestamp markers for navigating within the track. See [Chapters](./06-chapters.md).
14 + - **Downloads**: Fans can download the original file after purchase.
15 +
16 + Upload your audio file from the item's content tab. Large files are supported — there are no per-file size limits.
17 +
18 + ## Text
19 +
20 + Write directly in the markdown editor. Features include:
21 +
22 + - **Live preview**: See rendered output as you type
23 + - **Word count**: Automatically calculated
24 + - **Reading time**: Estimated based on word count
25 + - **Full markdown support**: Headers, lists, code blocks, links, images, tables
26 +
27 + Text content is stored and rendered on the platform. Fans read it in a clean, distraction-free view.
28 +
29 + ## Digital Downloads
30 +
31 + Upload any file type. Digital items support:
32 +
33 + - **Any format**: ZIP, DMG, EXE, PDF, images, fonts — whatever you make
34 + - **Versioned releases**: Upload new versions with changelogs. See [Versions](./05-versions.md).
35 + - **Download tracking**: See how many times each version has been downloaded
36 + - **License keys**: Auto-generated keys for software products. See [Pricing](./07-pricing.md).
37 +
38 + ## Blog Posts
39 +
40 + Every project includes a blog. Blog posts use the same markdown editor as text items, plus:
41 +
42 + - **Draft/publish workflow**: Write drafts, publish when ready
43 + - **Included in RSS**: Published posts appear in the project's RSS feed
44 + - **Included in data exports**: Blog posts export alongside all other project data
45 + - **Separate from items**: Blog posts don't appear in your item list — they have their own tab
46 +
47 + Blog posts are always free. Use them for updates, announcements, liner notes, or changelogs.
48 +
49 + ---
50 +
51 + ## See Also
52 +
53 + - [Chapters](./06-chapters.md) — Audio timestamp markers
54 + - [Versions](./05-versions.md) — Versioned file releases
55 + - [Items](./03-items.md) — Item types and settings
@@ -0,0 +1,51 @@
1 + # Versions
2 +
3 + Track releases of digital and audio items with version numbers and changelogs.
4 +
5 + ---
6 +
7 + ## Creating a Version
8 +
9 + From the item dashboard, add a new version:
10 +
11 + - **Version number** (required): Any string up to 50 characters (e.g., `1.0.0`, `v2`, `2024-03-01`)
12 + - **Changelog** (optional): Up to 10,000 characters describing what changed
13 + - **File**: Optionally attach a new file to this version
14 +
15 + ## How is_current Works
16 +
17 + Only one version per item can be marked as "current." When you create a new version:
18 +
19 + 1. All existing versions are marked `is_current = false`
20 + 2. The new version is marked `is_current = true`
21 +
22 + This happens atomically — there's no moment where zero or multiple versions are current.
23 +
24 + Fans downloading the item get the current version by default.
25 +
26 + ## Changelogs
27 +
28 + Use changelogs to tell fans what changed. Changelogs are plain text, up to 10,000 characters. Keep them concise — fans want to know what's new, not read a novel.
29 +
30 + ## Download Tracking
31 +
32 + Each version tracks its download count independently. This helps you see which versions fans are using.
33 +
34 + ## Listing Versions
35 +
36 + The public version list is ordered newest-first (by creation date). Only published items expose their version list — draft items return a 404.
37 +
38 + ## Validation
39 +
40 + | Field | Rule |
41 + |-------|------|
42 + | Version number | 1-50 characters, required |
43 + | Changelog | 0-10,000 characters, optional |
44 +
45 + ---
46 +
47 + ## See Also
48 +
49 + - [Items](./03-items.md) — Item types and settings
50 + - [Content Types](./04-content.md) — Upload formats
51 + - [Analytics & Data](./09-analytics.md) — Download tracking and exports
@@ -0,0 +1,46 @@
1 + # Chapters
2 +
3 + Chapters are timestamp markers for audio items. They let fans jump to specific sections within a track.
4 +
5 + ---
6 +
7 + ## Creating Chapters
8 +
9 + From the item dashboard, add chapters to any audio item:
10 +
11 + - **Title** (required): 1-200 characters
12 + - **Start seconds** (required): Timestamp where the chapter begins (e.g., `0`, `30.5`, `125`)
13 + - **Sort order**: Integer controlling display order (default: 0)
14 +
15 + ## Editing and Deleting
16 +
17 + Update any chapter's title, timestamp, or sort order. Delete chapters you no longer need. Changes take effect immediately.
18 +
19 + ## Ordering
20 +
21 + Chapters are displayed sorted by `sort_order` first, then by `start_seconds`. If all chapters have the same sort order, they appear in timestamp order.
22 +
23 + Create chapters in any order — the system sorts them for display.
24 +
25 + ## Ownership
26 +
27 + Only the item's creator can add, edit, or delete chapters. Other creators receive a 403 if they try.
28 +
29 + ## Validation
30 +
31 + | Field | Rule |
32 + |-------|------|
33 + | Title | 1-200 characters, required |
34 + | Start seconds | Non-negative number, required |
35 + | Sort order | Integer, default 0 |
36 +
37 + ## Draft Items
38 +
39 + Chapter lists are only visible for published items. If the item is still a draft, the chapters endpoint returns 404 for unauthenticated users. The creator can still manage chapters while the item is in draft.
40 +
41 + ---
42 +
43 + ## See Also
44 +
45 + - [Content Types](./04-content.md) — Audio uploads and playback
46 + - [Items](./03-items.md) — Item types and settings
@@ -0,0 +1,78 @@
1 + # Pricing & Monetization
2 +
3 + Set prices, accept payments, and manage monetization tools.
4 +
5 + ---
6 +
7 + ## Pricing Models
8 +
9 + | Model | How It Works |
10 + |-------|-------------|
11 + | **Free** | Price set to $0. Fans add to library without payment. |
12 + | **Fixed price** | You set the price. Fan pays exactly that amount. |
13 + | **Pay-what-you-want** | Fan chooses the amount. You can set a minimum. |
14 +
15 + Set prices per item when creating or editing.
16 +
17 + ## Subscriptions
18 +
19 + Offer recurring monthly subscriptions per project:
20 +
21 + - Create multiple tiers with different prices
22 + - Toggle tiers active/inactive without deleting them
23 + - Fans subscribe through Stripe Checkout
24 + - Stripe handles billing, renewals, and cancellations
25 +
26 + Subscription lifecycle events (active, past due, canceled, renewed) are processed automatically via Stripe webhooks.
27 +
28 + ## License Keys
29 +
30 + For software products, license keys are generated automatically on purchase:
31 +
32 + - **Configurable activation limits**: Set how many machines can activate a key
33 + - **Machine tracking**: See which machines have activated
34 + - **Public validation endpoint**: Your software can phone home to verify keys
35 + - **Revocable**: Deactivate keys if needed
36 +
37 + License keys appear in the fan's library after purchase.
38 +
39 + ## Discount Codes
40 +
41 + Create promotional codes with:
42 +
43 + - **Percentage or fixed amount** off the price
44 + - **Scope**: Apply to a specific item or all your items
45 + - **Usage limits**: Cap how many times a code can be used
46 + - **Expiration dates**: Codes stop working after a date you set
47 + - **Auto-apply via URL**: Share a link with `?discount=CODE` to pre-fill
48 +
49 + ## Download Codes
50 +
51 + Generate single-use codes for free access:
52 +
53 + - **Batch generation**: Create many codes at once
54 + - **Optional max uses**: Limit how many times each code works
55 + - **Optional expiration**: Codes expire after a date
56 + - **Useful for**: Press copies, review access, promotional giveaways
57 +
58 + ## Payment Flow
59 +
60 + All payments go through Stripe Connect:
61 +
62 + ```
63 + Fan pays → Stripe → Creator's connected account
64 + ```
65 +
66 + We take 0% platform fee. Only Stripe's processing fee (~3%) applies.
67 +
68 + ## Refunds
69 +
70 + Refund policies are set by individual creators. Refunds are processed through Stripe.
71 +
72 + ---
73 +
74 + ## See Also
75 +
76 + - [How We Work](../about/how-we-work.md) — Business model and platform pricing
77 + - [Items](./03-items.md) — Setting item prices
78 + - [Audience & Communication](./08-audience.md) — Building your fan base
@@ -0,0 +1,55 @@
1 + # Audience & Communication
2 +
3 + Build and maintain your fan base. Communicate directly.
4 +
5 + ---
6 +
7 + ## Follows
8 +
9 + Fans can follow your user account, individual projects, or tags. Follows power:
10 +
11 + - **Personal feed**: Fans see new items and blog posts from followed creators
12 + - **Follower counts**: Visible on your profile and project pages
13 +
14 + Following is free and doesn't require a purchase.
15 +
16 + ## Contact Sharing
17 +
18 + When a fan purchases from you, they can opt in to share their email address. This creates a direct connection you can use outside the platform.
19 +
20 + ### How It Works
21 +
22 + 1. At checkout, the fan sees a "Share your email with this creator" option
23 + 2. If they opt in, their email appears in your contacts
24 + 3. Fans can revoke sharing at any time via `DELETE /api/contacts/{seller_id}`
25 + 4. Revocation is idempotent — revoking twice is fine
26 +
27 + ### For Fans
28 +
29 + You control your contact info. Shared your email and changed your mind? Revoke it. The creator loses access immediately.
30 +
31 + ### For Creators
32 +
33 + Respect revocations. Don't export contact lists and continue emailing fans who revoked. The platform enforces revocation — build trust by honoring it.
34 +
35 + ## Broadcast Email
36 +
37 + Send updates to fans who opted in to contact sharing. Planned but not yet live — see the [Roadmap](../about/roadmap.md).
38 +
39 + ## RSS
40 +
41 + Every project generates an RSS feed automatically. Fans subscribe via any feed reader. Feeds include both published items and blog posts.
42 +
43 + Fans also get a personal feed across all creators and projects they follow.
44 +
45 + ## Notifications
46 +
47 + Sale and follower notifications via email are planned. Currently, check your Dashboard for transaction history.
48 +
49 + ---
50 +
51 + ## See Also
52 +
53 + - [For Fans](./12-for-fans.md) — The fan experience
54 + - [Analytics & Data](./09-analytics.md) — Tracking your audience
55 + - [Profile & Customization](./11-profile.md) — Your public presence
@@ -0,0 +1,53 @@
1 + # Analytics & Data
2 +
3 + Track performance, review transactions, and export everything.
4 +
5 + ---
6 +
7 + ## Dashboard Analytics
8 +
9 + Your creator dashboard shows:
10 +
11 + - Revenue and sales counts per project
12 + - Recent transactions
13 + - Follower counts
14 +
15 + Full analytics with charts and trends are planned. The current dashboard gives you the numbers that matter.
16 +
17 + ## Transaction History
18 +
19 + View complete purchase and sales history from your Dashboard. Transactions include:
20 +
21 + - Buyer info (username, email if shared)
22 + - Item and project
23 + - Amount paid
24 + - Payment status
25 + - Date
26 +
27 + Filter by project, date range, or status.
28 +
29 + ## Data Exports
30 +
31 + Export all your data at any time. Five export types are available:
32 +
33 + | Export | Format | Contents |
34 + |--------|--------|----------|
35 + | **Projects** | JSON | All project metadata, settings, categories |
36 + | **Items** | JSON | All items with metadata, pricing, tags |
37 + | **Blog posts** | JSON | All blog posts with content and publish status |
38 + | **Sales** | CSV | Complete sales transaction history |
39 + | **Purchases** | CSV | Your purchase history as a fan |
40 +
41 + Exports are generated on demand and download as files. No limits on how often you export.
42 +
43 + ## Your Data Rights
44 +
45 + You own your data. We facilitate hosting and delivery, but everything is exportable. If you leave, you take it all with you. See [How We Work](../about/how-we-work.md) for the full data ownership commitment.
46 +
47 + ---
48 +
49 + ## See Also
50 +
51 + - [Versions](./05-versions.md) — Download tracking per version
52 + - [Pricing & Monetization](./07-pricing.md) — Revenue and payments
53 + - [Audience & Communication](./08-audience.md) — Fan relationships
@@ -0,0 +1,61 @@
1 + # Security
2 +
3 + Protect your account with multiple layers of authentication.
4 +
5 + ---
6 +
7 + ## Two-Factor Authentication (2FA/TOTP)
8 +
9 + Add time-based one-time passwords as a second factor:
10 +
11 + 1. Go to your account security settings
12 + 2. Scan the QR code with an authenticator app (Google Authenticator, Authy, 1Password, etc.)
13 + 3. Enter the 6-digit code to confirm setup
14 + 4. Save your 10 backup codes somewhere safe
15 +
16 + When 2FA is enabled, you'll enter a code from your authenticator app after your password on each login.
17 +
18 + ### Backup Codes
19 +
20 + You get 10 single-use backup codes at setup. Each code works once. If you lose access to your authenticator app, use a backup code to log in, then reconfigure 2FA.
21 +
22 + ## Passkeys (WebAuthn)
23 +
24 + Passkeys offer passwordless, phishing-resistant login:
25 +
26 + - Register a passkey from your security settings (fingerprint, Face ID, hardware key)
27 + - Log in by touching your device — no password needed
28 + - Multiple passkeys supported (register your phone, laptop, and a hardware key)
29 + - Phishing-resistant by design — passkeys are bound to the domain
30 +
31 + ## Session Management
32 +
33 + View and control active sessions:
34 +
35 + - See all active sessions with device type, IP address, and last activity
36 + - Revoke individual sessions (log out a specific device)
37 + - Revoke all other sessions at once (nuclear option)
38 +
39 + ## Account Lockout
40 +
41 + After 5 failed login attempts, your account locks for 15 minutes. During lockout:
42 +
43 + - Login attempts are rejected regardless of password
44 + - An email is sent with a bypass link (proves you control the email)
45 + - Lockout clears automatically after 15 minutes
46 +
47 + ## New Device Notifications
48 +
49 + Opt in to receive an email whenever a new device logs into your account. Useful for detecting unauthorized access early.
50 +
51 + ## Password Breach Checking
52 +
53 + On signup and password change, your password is checked against the Have I Been Pwned database using k-anonymity (your password is never sent to HIBP). If your password appears in known breaches, you'll get a warning. It's advisory — you can proceed, but you should pick a stronger password.
54 +
55 + ---
56 +
57 + ## See Also
58 +
59 + - [Getting Started](./01-getting-started.md) — Account setup
60 + - [Profile & Customization](./11-profile.md) — Account settings
61 + - [FAQ](../support/faq.md) — Security questions
@@ -0,0 +1,46 @@
1 + # Profile & Customization
2 +
3 + Your public presence on the platform.
4 +
5 + ---
6 +
7 + ## Your Profile Page
8 +
9 + Every account gets a profile at `/u/yourname`. It shows:
10 +
11 + - Display name and username
12 + - Bio
13 + - Avatar and cover image
14 + - Published projects and items
15 + - Custom links
16 + - Follower count
17 +
18 + ## Display Name
19 +
20 + Your display name appears on your profile, in search results, and on item pages. It can be different from your username. Update it from account settings.
21 +
22 + ## Bio
23 +
24 + A short description shown on your profile page. Keep it concise — this is the first thing fans see.
25 +
26 + ## Avatar & Cover Image
27 +
28 + Upload a profile avatar and a cover image for your profile page. Images are resized automatically.
29 +
30 + ## Custom Links
31 +
32 + Add external links to your profile — your website, social media, other platforms. Each link has a title and URL. Links appear on your profile page.
33 +
34 + Manage links from your Dashboard. Add, edit, reorder, or remove links at any time.
35 +
36 + ## Username
37 +
38 + Your username is set at signup and determines your profile URL (`/u/yourname`). Choose carefully — it's part of your public identity on the platform.
39 +
40 + ---
41 +
42 + ## See Also
43 +
44 + - [Getting Started](./01-getting-started.md) — Account creation
45 + - [Security](./10-security.md) — Account protection
46 + - [Audience & Communication](./08-audience.md) — Building your audience
@@ -0,0 +1,71 @@
1 + # For Fans
2 +
3 + Everything you can do as a fan on Makenot.work — no creator subscription needed.
4 +
5 + ---
6 +
7 + ## Discover
8 +
9 + Browse content on the Discover page:
10 +
11 + - **Search**: Full-text fuzzy search across titles, descriptions, and usernames
12 + - **Filters**: Filter by item type (Audio, Text, Digital), price range, tags, and project category
13 + - **Sort**: Newest, price (low/high), or popularity
14 +
15 + ## Purchasing
16 +
17 + When you find something you want:
18 +
19 + 1. Click to view the item
20 + 2. Click **Buy** (or **Add to Library** for free items)
21 + 3. For paid items, complete checkout through Stripe
22 + 4. The item appears in your Library immediately
23 +
24 + ### Pay-What-You-Want
25 +
26 + Some items let you choose your price. You'll see a minimum (which can be $0) and can pay more if you want to support the creator.
27 +
28 + ### Contact Sharing
29 +
30 + At checkout, you can opt in to share your email with the creator. This is completely optional. If you share and later change your mind, you can revoke access anytime.
31 +
32 + ## Library
33 +
34 + Your Library holds everything you've purchased or claimed:
35 +
36 + - **Download files**: Original quality, no DRM
37 + - **View license keys**: For software purchases
38 + - **Stream audio**: Play in the browser
39 + - **Read text**: Clean reading view
40 +
41 + Access your Library from the navigation bar when logged in.
42 +
43 + ## Following
44 +
45 + Follow creators, projects, or tags to build a personalized feed:
46 +
47 + - **Follow a creator**: See all their new content
48 + - **Follow a project**: See new items and blog posts from that project
49 + - **Follow a tag**: See new items tagged with topics you care about
50 +
51 + Your feed shows new content from everything you follow.
52 +
53 + ## Contact Sharing & Revocation
54 +
55 + If you shared your email with a creator during a purchase and want to revoke it:
56 +
57 + - The creator immediately loses access to your email
58 + - Revocation is permanent until you make a new purchase and opt in again
59 + - You can revoke even if you made multiple purchases from the same creator
60 +
61 + ## Free Accounts
62 +
63 + Fan accounts are free. You never pay the platform — only for content you choose to buy.
64 +
65 + ---
66 +
67 + ## See Also
68 +
69 + - [Getting Started](./01-getting-started.md) — Creating your account
70 + - [Audience & Communication](./08-audience.md) — How contact sharing works
71 + - [FAQ](../support/faq.md) — Common questions
@@ -1,6 +1,6 @@
1 1 CREATE TABLE contact_revocations (
2 - buyer_id UUID NOT NULL REFERENCES users(id),
3 - seller_id UUID NOT NULL REFERENCES users(id),
2 + buyer_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
3 + seller_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
4 4 revoked_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
5 5 PRIMARY KEY (buyer_id, seller_id)
6 6 );
@@ -0,0 +1 @@
1 + CREATE INDEX idx_transactions_stripe_payment_intent_id ON transactions(stripe_payment_intent_id);
@@ -8,11 +8,19 @@
8 8 //! mnw-admin spam <username> Mark application as spam
9 9 //! mnw-admin wave <lottery_count> Run a wave (hand-picks + lottery)
10 10 //! mnw-admin stats Show waitlist/creator counts
11 + //! mnw-admin suspend <user> <why> Suspend a user account
12 + //! mnw-admin unsuspend <user> Lift a suspension
13 + //! mnw-admin appeals List pending appeals
14 + //! mnw-admin decide <user> <d> <r> Approve or deny an appeal
15 + //! mnw-admin revenue Platform-wide revenue report
16 + //! mnw-admin transactions <user> Recent sales for a user
17 + //! mnw-admin export <user> CSV export of a user's sales
18 + //! mnw-admin storage <user> S3 storage audit for a user
11 19
12 20 use clap::{Parser, Subcommand};
13 21 use sqlx::PgPool;
14 22
15 - use makenotwork::db::{self, SelectionMethod, Username, WaitlistStatus};
23 + use makenotwork::db::{self, AppealDecision, SelectionMethod, Username, WaitlistStatus};
16 24
17 25 #[derive(Parser)]
18 26 #[command(name = "mnw-admin", about = "MNW admin CLI")]
@@ -42,6 +50,46 @@ enum Command {
42 50 },
43 51 /// Show waitlist and creator statistics
44 52 Stats,
53 + /// Suspend a user account
54 + Suspend {
55 + /// Username to suspend
56 + username: String,
57 + /// Reason for suspension
58 + reason: String,
59 + },
60 + /// Lift a user's suspension
61 + Unsuspend {
62 + /// Username to unsuspend
63 + username: String,
64 + },
65 + /// List pending suspension appeals
66 + Appeals,
67 + /// Decide a suspension appeal (approve or deny)
68 + Decide {
69 + /// Username whose appeal to decide
70 + username: String,
71 + /// Decision: "approved" or "denied"
72 + decision: String,
73 + /// Response message to the user
74 + response: String,
75 + },
76 + /// Show platform-wide revenue report
77 + Revenue,
78 + /// Show recent transactions for a seller
79 + Transactions {
80 + /// Username to look up
81 + username: String,
82 + },
83 + /// Export a seller's transactions as CSV to stdout
84 + Export {
85 + /// Username to export
86 + username: String,
87 + },
88 + /// Audit S3 storage usage for a user
89 + Storage {
90 + /// Username to audit
91 + username: String,
92 + },
45 93 }
46 94
47 95 #[tokio::main]
@@ -60,11 +108,23 @@ async fn main() -> anyhow::Result<()> {
60 108 Command::Spam { username } => cmd_spam(&pool, &username).await?,
61 109 Command::Wave { lottery_count } => cmd_wave(&pool, lottery_count).await?,
62 110 Command::Stats => cmd_stats(&pool).await?,
111 + Command::Suspend { username, reason } => cmd_suspend(&pool, &username, &reason).await?,
112 + Command::Unsuspend { username } => cmd_unsuspend(&pool, &username).await?,
113 + Command::Appeals => cmd_appeals(&pool).await?,
114 + Command::Decide { username, decision, response } => {
115 + cmd_decide(&pool, &username, &decision, &response).await?
116 + }
117 + Command::Revenue => cmd_revenue(&pool).await?,
118 + Command::Transactions { username } => cmd_transactions(&pool, &username).await?,
119 + Command::Export { username } => cmd_export(&pool, &username).await?,
120 + Command::Storage { username } => cmd_storage(&pool, &username).await?,
63 121 }
64 122
65 123 Ok(())
66 124 }
67 125
126 + // ── Waitlist commands (existing) ──
127 +
68 128 async fn cmd_waitlist(pool: &PgPool) -> anyhow::Result<()> {
69 129 let entries = db::waitlist::get_admin_waitlist(pool, Some("pending")).await?;
70 130
@@ -240,3 +300,284 @@ async fn cmd_stats(pool: &PgPool) -> anyhow::Result<()> {
240 300
241 301 Ok(())
242 302 }
303 +
304 + // ── Suspension commands ──
305 +
306 + async fn cmd_suspend(pool: &PgPool, username_str: &str, reason: &str) -> anyhow::Result<()> {
307 + let username = Username::new(username_str)
308 + .map_err(|e| anyhow::anyhow!("invalid username: {e}"))?;
309 +
310 + let user = db::users::get_user_by_username(pool, &username)
311 + .await?
312 + .ok_or_else(|| anyhow::anyhow!("user '{}' not found", username_str))?;
313 +
314 + if user.is_suspended() {
315 + println!("'{}' is already suspended.", username_str);
316 + return Ok(());
317 + }
318 +
319 + db::users::suspend_user(pool, user.id, reason).await?;
320 +
321 + println!("Suspended '{}'. Reason: {}", username_str, reason);
322 + Ok(())
323 + }
324 +
325 + async fn cmd_unsuspend(pool: &PgPool, username_str: &str) -> anyhow::Result<()> {
326 + let username = Username::new(username_str)
327 + .map_err(|e| anyhow::anyhow!("invalid username: {e}"))?;
328 +
329 + let user = db::users::get_user_by_username(pool, &username)
330 + .await?
331 + .ok_or_else(|| anyhow::anyhow!("user '{}' not found", username_str))?;
332 +
333 + if !user.is_suspended() {
334 + println!("'{}' is not suspended.", username_str);
335 + return Ok(());
336 + }
337 +
338 + db::users::unsuspend_user(pool, user.id).await?;
339 +
340 + println!("Unsuspended '{}'.", username_str);
341 + Ok(())
342 + }
343 +
344 + // ── Appeal commands ──
345 +
346 + async fn cmd_appeals(pool: &PgPool) -> anyhow::Result<()> {
347 + let users = db::users::get_pending_appeals(pool).await?;
348 +
349 + if users.is_empty() {
350 + println!("No pending appeals.");
351 + return Ok(());
352 + }
353 +
354 + println!(
355 + "{:<20} {:<30} {:<12} {:<12} {}",
356 + "Username", "Email", "Suspended", "Appeal Date", "Appeal Text"
357 + );
358 + println!("{}", "-".repeat(110));
359 +
360 + for user in &users {
361 + let suspended = user
362 + .suspended_at
363 + .map(|t| t.format("%Y-%m-%d").to_string())
364 + .unwrap_or_else(|| "-".to_string());
365 + let appeal_date = user
366 + .appeal_submitted_at
367 + .map(|t| t.format("%Y-%m-%d").to_string())
368 + .unwrap_or_else(|| "-".to_string());
369 + let appeal = user.appeal_text.as_deref().unwrap_or("");
370 + let appeal_short = if appeal.len() > 50 {
371 + format!("{}...", &appeal[..50])
372 + } else {
373 + appeal.to_string()
374 + };
375 + println!(
376 + "{:<20} {:<30} {:<12} {:<12} {}",
377 + user.username, user.email, suspended, appeal_date, appeal_short
378 + );
379 + }
380 +
381 + println!("\n{} pending appeal(s).", users.len());
382 + Ok(())
383 + }
384 +
385 + async fn cmd_decide(
386 + pool: &PgPool,
387 + username_str: &str,
388 + decision_str: &str,
389 + response: &str,
390 + ) -> anyhow::Result<()> {
391 + let username = Username::new(username_str)
392 + .map_err(|e| anyhow::anyhow!("invalid username: {e}"))?;
393 +
394 + let user = db::users::get_user_by_username(pool, &username)
395 + .await?
396 + .ok_or_else(|| anyhow::anyhow!("user '{}' not found", username_str))?;
397 +
398 + let decision: AppealDecision = decision_str
399 + .parse()
400 + .map_err(|_| anyhow::anyhow!("invalid decision '{}': use 'approved' or 'denied'", decision_str))?;
401 +
402 + db::users::resolve_appeal(pool, user.id, decision, response).await?;
403 +
404 + match decision {
405 + AppealDecision::Approved => {
406 + println!("Appeal approved for '{}'. Suspension lifted.", username_str);
407 + }
408 + AppealDecision::Denied => {
409 + println!("Appeal denied for '{}'. Suspension remains.", username_str);
410 + }
411 + }
412 + Ok(())
413 + }
414 +
415 + // ── Revenue & transaction commands ──
416 +
417 + async fn cmd_revenue(pool: &PgPool) -> anyhow::Result<()> {
418 + let (revenue_cents, completed, refunded) =
419 + db::transactions::get_platform_revenue_stats(pool).await?;
420 +
421 + let dollars = revenue_cents as f64 / 100.0;
422 +
423 + println!("Platform Revenue");
424 + println!(" Total revenue: ${:.2}", dollars);
425 + println!(" Total sales: {}", completed);
426 + println!(" Total refunds: {}", refunded);
427 +
428 + Ok(())
429 + }
430 +
431 + async fn cmd_transactions(pool: &PgPool, username_str: &str) -> anyhow::Result<()> {
432 + let username = Username::new(username_str)
433 + .map_err(|e| anyhow::anyhow!("invalid username: {e}"))?;
434 +
435 + let user = db::users::get_user_by_username(pool, &username)
436 + .await?
437 + .ok_or_else(|| anyhow::anyhow!("user '{}' not found", username_str))?;
438 +
439 + let txs = db::transactions::get_transactions_by_seller(pool, user.id, Some(50)).await?;
440 +
441 + if txs.is_empty() {
442 + println!("No transactions for '{}'.", username_str);
443 + return Ok(());
444 + }
445 +
446 + println!(
447 + "{:<12} {:<30} {:>10} {:<10}",
448 + "Date", "Item", "Amount", "Status"
449 + );
450 + println!("{}", "-".repeat(65));
451 +
452 + let mut total_cents: i64 = 0;
453 + for tx in &txs {
454 + let date = tx.created_at.format("%Y-%m-%d");
455 + let title = tx.item_title.as_deref().unwrap_or("(deleted)");
456 + let title_short = if title.len() > 28 {
457 + format!("{}...", &title[..25])
458 + } else {
459 + title.to_string()
460 + };
461 + let amount = format!("${:.2}", tx.amount_cents as f64 / 100.0);
462 + println!(
463 + "{:<12} {:<30} {:>10} {:<10}",
464 + date, title_short, amount, tx.status
465 + );
466 + if tx.status.to_string() == "completed" {
467 + total_cents += tx.amount_cents as i64;
468 + }
469 + }
470 +
471 + println!(
472 + "\n{} transaction(s), ${:.2} total revenue.",
473 + txs.len(),
474 + total_cents as f64 / 100.0
475 + );
476 + Ok(())
477 + }
478 +
479 + // ── Export command ──
480 +
481 + async fn cmd_export(pool: &PgPool, username_str: &str) -> anyhow::Result<()> {
482 + let username = Username::new(username_str)
483 + .map_err(|e| anyhow::anyhow!("invalid username: {e}"))?;
484 +
485 + let user = db::users::get_user_by_username(pool, &username)
486 + .await?
487 + .ok_or_else(|| anyhow::anyhow!("user '{}' not found", username_str))?;
488 +
489 + let rows = db::transactions::get_seller_transactions_for_export(pool, user.id).await?;
490 +
491 + // CSV header
492 + println!("date,item_id,item_title,amount_cents,status,buyer_email");
493 +
494 + for row in &rows {
495 + let date = row.created_at.format("%Y-%m-%dT%H:%M:%SZ");
496 + let item_id = row
497 + .item_id
498 + .map(|id| id.to_string())
499 + .unwrap_or_default();
500 + let title = csv_escape(row.item_title.as_deref().unwrap_or(""));
501 + let email = csv_escape(row.buyer_email.as_deref().unwrap_or(""));
502 + println!(
503 + "{},{},{},{},{},{}",
504 + date, item_id, title, row.amount_cents, row.status, email
505 + );
506 + }
507 +
508 + Ok(())
509 + }
510 +
511 + /// Escape a value for CSV: wrap in quotes if it contains comma, quote, or newline.
512 + fn csv_escape(s: &str) -> String {
513 + if s.contains(',') || s.contains('"') || s.contains('\n') {
514 + format!("\"{}\"", s.replace('"', "\"\""))
515 + } else {
516 + s.to_string()
517 + }
518 + }
519 +
520 + // ── Storage audit command ──
521 +
522 + async fn cmd_storage(pool: &PgPool, username_str: &str) -> anyhow::Result<()> {
523 + let username = Username::new(username_str)
524 + .map_err(|e| anyhow::anyhow!("invalid username: {e}"))?;
525 +
526 + let user = db::users::get_user_by_username(pool, &username)
527 + .await?
528 + .ok_or_else(|| anyhow::anyhow!("user '{}' not found", username_str))?;
529 +
530 + let item_keys = db::items::get_user_s3_keys(pool, user.id).await?;
531 + let version_keys = db::versions::get_user_version_s3_keys(pool, user.id).await?;
532 +
533 + if item_keys.is_empty() && version_keys.is_empty() {
534 + println!("No S3 files for '{}'.", username_str);
535 + return Ok(());
536 + }
537 +
538 + println!(
539 + "{:<10} {:<20} {:<25} {}",
540 + "Type", "Project", "Item", "S3 Key"
541 + );
542 + println!("{}", "-".repeat(100));
543 +
544 + let mut item_file_count = 0u32;
545 + for row in &item_keys {
546 + if let Some(key) = &row.audio_s3_key {
547 + println!(
548 + "{:<10} {:<20} {:<25} {}",
549 + "audio", row.project_slug, row.title, key
550 + );
551 + item_file_count += 1;
552 + }
553 + if let Some(key) = &row.cover_s3_key {
554 + println!(
555 + "{:<10} {:<20} {:<25} {}",
556 + "cover", row.project_slug, row.title, key
557 + );
558 + item_file_count += 1;
559 + }
560 + }
561 +
562 + for row in &version_keys {
563 + if let Some(key) = &row.s3_key {
564 + let label = format!("{} v{}", row.item_title, row.version_number);
565 + let label_short = if label.len() > 23 {
566 + format!("{}...", &label[..20])
567 + } else {
568 + label
569 + };
570 + println!(
571 + "{:<10} {:<20} {:<25} {}",
572 + "version", row.project_slug, label_short, key
573 + );
574 + }
575 + }
576 +
577 + let version_file_count = version_keys.iter().filter(|r| r.s3_key.is_some()).count();
578 + println!(
579 + "\n{} item file(s), {} version file(s).",
580 + item_file_count, version_file_count
581 + );
582 + Ok(())
583 + }
@@ -127,7 +127,10 @@ pub async fn csrf_middleware(request: Request, next: Next) -> Response {
127 127 // - Stripe checkout is a vanilla form POST that redirects to Stripe's hosted page;
128 128 // SameSite=Strict cookies prevent cross-origin CSRF, AuthUser is required,
129 129 // and no state mutation occurs until Stripe's webhook confirms payment
130 - let exempt_paths = ["/stripe/webhook", "/stripe/checkout", "/stripe/subscribe", "/login", "/join", "/api/sync", "/oauth", "/auth/passkey", "/postmark/webhook", "/unsubscribe"];
130 + // - /confirm-delete uses a signed HMAC link as its authorization; the user
131 + // arrives from an email and may not have an active session, so the
132 + // standard CSRF header cannot be attached to the vanilla form POST.
133 + let exempt_paths = ["/stripe/webhook", "/stripe/checkout", "/stripe/subscribe", "/login", "/join", "/api/sync", "/oauth", "/auth/passkey", "/postmark/webhook", "/unsubscribe", "/confirm-delete"];
131 134
132 135 if exempt_paths.iter().any(|p| path.starts_with(p)) {
133 136 return next.run(request).await;
@@ -4,7 +4,7 @@ use chrono::{DateTime, Utc};
4 4 use sqlx::PgPool;
5 5
6 6 use super::models::*;
7 - use super::{LoginTokenId, UserId};
7 + use super::UserId;
8 8 use crate::error::Result;
9 9
10 10 /// Increment failed login attempts for a user
@@ -71,17 +71,24 @@ pub async fn create_login_token(
71 71 Ok(token)
72 72 }
73 73
74 - /// Get a login token by hash (only if not used and not expired)
75 - pub async fn get_valid_login_token(
74 + /// Atomically consume a login token: mark it used and return it in one step.
75 + ///
76 + /// Returns `Some(token)` if the token was valid and successfully consumed,
77 + /// or `None` if the token was already used, expired, or does not exist.
78 + /// Because this is a single UPDATE with `used_at IS NULL` in the WHERE clause,
79 + /// concurrent requests for the same token will never both succeed.
80 + pub async fn consume_login_token(
76 81 pool: &PgPool,
77 82 token_hash: &str,
78 83 ) -> Result<Option<DbLoginToken>> {
79 84 let token = sqlx::query_as::<_, DbLoginToken>(
80 85 r#"
81 - SELECT * FROM login_tokens
86 + UPDATE login_tokens
87 + SET used_at = NOW()
82 88 WHERE token_hash = $1
83 89 AND used_at IS NULL
84 90 AND expires_at > NOW()
91 + RETURNING *
85 92 "#,
86 93 )
87 94 .bind(token_hash)
@@ -91,13 +98,3 @@ pub async fn get_valid_login_token(
91 98 Ok(token)
92 99 }
93 100
94 - /// Mark a login token as used
95 - pub async fn mark_login_token_used(pool: &PgPool, token_id: LoginTokenId) -> Result<()> {
96 - sqlx::query("UPDATE login_tokens SET used_at = NOW() WHERE id = $1")
97 - .bind(token_id)
98 - .execute(pool)
99 - .await?;
100 -
101 - Ok(())
102 - }
103 -
@@ -102,12 +102,18 @@ pub async fn get_discount_code_by_seller_and_code(
102 102 /// reached its usage limit. The `WHERE` clause enforces the limit at the
103 103 /// database level, preventing TOCTOU races where concurrent requests both
104 104 /// read `use_count < max_uses` and both increment past the limit.
105 - pub async fn try_increment_discount_code_use_count(pool: &PgPool, id: DiscountCodeId) -> Result<bool> {
105 + ///
106 + /// Accepts any sqlx executor (`&PgPool`, `&mut Transaction`, etc.) so callers
107 + /// can include this in a larger transaction when needed.
108 + pub async fn try_increment_discount_code_use_count<'e>(
109 + executor: impl sqlx::PgExecutor<'e>,
110 + id: DiscountCodeId,
111 + ) -> Result<bool> {
106 112 let result = sqlx::query(
107 113 "UPDATE discount_codes SET use_count = use_count + 1 WHERE id = $1 AND (max_uses IS NULL OR use_count < max_uses)",
108 114 )
109 115 .bind(id)
110 - .execute(pool)
116 + .execute(executor)
111 117 .await?;
112 118
113 119 Ok(result.rows_affected() > 0)
@@ -20,13 +20,3 @@ pub async fn add_suppression(pool: &PgPool, email: &str, reason: &str) -> Result
20 20
21 21 Ok(())
22 22 }
23 -
24 - /// Remove a suppression (admin un-suppression).
25 - pub async fn remove_suppression(pool: &PgPool, email: &str) -> Result<bool> {
26 - let result = sqlx::query("DELETE FROM email_suppressions WHERE LOWER(email) = LOWER($1)")
27 - .bind(email)
28 - .execute(pool)
29 - .await?;
30 -
31 - Ok(result.rows_affected() > 0)
32 - }