Sfoglia il codice sorgente

feat: send real invite emails via Resend (skips .local domains)

- Add Resend SDK for transactional email delivery
- Beautiful HTML + plain-text invite email template matching app branding
- sendInviteEmail() called after every invite creation:
  - POST /api/invitations/workspace (workspace MEMBER invite)
  - POST /api/invitations/project/:id (project invite)
  - POST /api/invitations (admin project invite)
- Skips email for @*.local addresses (dev/test) and when RESEND_API_KEY is not set
- Lazy Resend client init — API starts fine without API key
- RESEND_API_KEY added to docker-compose API env
- Fix Prisma schema: named relations to resolve ambiguity
  between Project.owner (User) and Project.members (ProjectMember)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Claude Dev 1 mese fa
parent
commit
127205a60b

+ 1 - 1
docker-compose.yml

@@ -58,8 +58,8 @@ services:
       UPLOAD_DIR: /app/uploads
       MAX_FILE_SIZE_MB: 500
       ALLOWED_ORIGINS: '*'
-      # Public domain for invite link generation
       FRONTEND_URL: https://vid.k9tech.space
+      RESEND_API_KEY: ${RESEND_API_KEY:-}
     ports:
       - '3001:3001'
     depends_on:

+ 614 - 0
package-lock.json

@@ -1226,6 +1226,362 @@
         "@prisma/debug": "5.22.0"
       }
     },
+    "node_modules/@react-email/body": {
+      "version": "0.3.0",
+      "resolved": "https://registry.npmjs.org/@react-email/body/-/body-0.3.0.tgz",
+      "integrity": "sha512-uGo0BOOzjbMUo3lu+BIDWayvn5o6Xyfmnlla5VGf05n8gHMvO1ll7U4FtzWe3hxMLwt53pmc4iE0M+B5slG+Ug==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=20.0.0"
+      },
+      "peerDependencies": {
+        "react": "^18.0 || ^19.0 || ^19.0.0-rc"
+      }
+    },
+    "node_modules/@react-email/button": {
+      "version": "0.2.1",
+      "resolved": "https://registry.npmjs.org/@react-email/button/-/button-0.2.1.tgz",
+      "integrity": "sha512-qXyj7RZLE7POy9BMKSoqQ00tOXThjOZSUnI2Yu9i29IHngPlmrNayIWBoVKtElES7OWwypUcpiajwi1mUWx6/A==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=20.0.0"
+      },
+      "peerDependencies": {
+        "react": "^18.0 || ^19.0 || ^19.0.0-rc"
+      }
+    },
+    "node_modules/@react-email/code-block": {
+      "version": "0.2.1",
+      "resolved": "https://registry.npmjs.org/@react-email/code-block/-/code-block-0.2.1.tgz",
+      "integrity": "sha512-M3B7JpVH4ytgn83/ujRR1k1DQHvTeABiDM61OvAbjLRPhC/5KLHU5KkzIbbuGIrjWwxAbL1kSQzU8MhLEtSxyw==",
+      "license": "MIT",
+      "dependencies": {
+        "prismjs": "^1.30.0"
+      },
+      "engines": {
+        "node": ">=20.0.0"
+      },
+      "peerDependencies": {
+        "react": "^18.0 || ^19.0 || ^19.0.0-rc"
+      }
+    },
+    "node_modules/@react-email/code-inline": {
+      "version": "0.0.6",
+      "resolved": "https://registry.npmjs.org/@react-email/code-inline/-/code-inline-0.0.6.tgz",
+      "integrity": "sha512-jfhebvv3dVsp3OdPgKXnk8+e2pBiDVZejDOBFzBa/IblrAJ9cQDkN6rBD5IyEg8hTOxwbw3iaI/yZFmDmIguIA==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=20.0.0"
+      },
+      "peerDependencies": {
+        "react": "^18.0 || ^19.0 || ^19.0.0-rc"
+      }
+    },
+    "node_modules/@react-email/column": {
+      "version": "0.0.14",
+      "resolved": "https://registry.npmjs.org/@react-email/column/-/column-0.0.14.tgz",
+      "integrity": "sha512-f+W+Bk2AjNO77zynE33rHuQhyqVICx4RYtGX9NKsGUg0wWjdGP0qAuIkhx9Rnmk4/hFMo1fUrtYNqca9fwJdHg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=20.0.0"
+      },
+      "peerDependencies": {
+        "react": "^18.0 || ^19.0 || ^19.0.0-rc"
+      }
+    },
+    "node_modules/@react-email/components": {
+      "version": "1.0.11",
+      "resolved": "https://registry.npmjs.org/@react-email/components/-/components-1.0.11.tgz",
+      "integrity": "sha512-s0CX31+S/u1MhBWYFAuZru0NHNExTY+OeZC9OrGyzl8PGQ0Iz/4gq3O4rHUVuA1D7FjAcPbwG1Up0yey/Xh6dw==",
+      "license": "MIT",
+      "dependencies": {
+        "@react-email/body": "0.3.0",
+        "@react-email/button": "0.2.1",
+        "@react-email/code-block": "0.2.1",
+        "@react-email/code-inline": "0.0.6",
+        "@react-email/column": "0.0.14",
+        "@react-email/container": "0.0.16",
+        "@react-email/font": "0.0.10",
+        "@react-email/head": "0.0.13",
+        "@react-email/heading": "0.0.16",
+        "@react-email/hr": "0.0.12",
+        "@react-email/html": "0.0.12",
+        "@react-email/img": "0.0.12",
+        "@react-email/link": "0.0.13",
+        "@react-email/markdown": "0.0.18",
+        "@react-email/preview": "0.0.14",
+        "@react-email/render": "2.0.5",
+        "@react-email/row": "0.0.13",
+        "@react-email/section": "0.0.17",
+        "@react-email/tailwind": "2.0.7",
+        "@react-email/text": "0.1.6"
+      },
+      "engines": {
+        "node": ">=20.0.0"
+      },
+      "peerDependencies": {
+        "react": "^18.0 || ^19.0 || ^19.0.0-rc"
+      }
+    },
+    "node_modules/@react-email/container": {
+      "version": "0.0.16",
+      "resolved": "https://registry.npmjs.org/@react-email/container/-/container-0.0.16.tgz",
+      "integrity": "sha512-QWBB56RkkU0AJ9h+qy33gfT5iuZknPC7Un/IjZv9B0QmMIK+WWacc0cH6y2SV5Cv/b99hU94fjEMOOO4enpkbQ==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=20.0.0"
+      },
+      "peerDependencies": {
+        "react": "^18.0 || ^19.0 || ^19.0.0-rc"
+      }
+    },
+    "node_modules/@react-email/font": {
+      "version": "0.0.10",
+      "resolved": "https://registry.npmjs.org/@react-email/font/-/font-0.0.10.tgz",
+      "integrity": "sha512-0urVSgCmQIfx5r7Xc586miBnQUVnGp3OTYUm8m5pwtQRdTRO5XrTtEfNJ3JhYhSOruV0nD8fd+dXtKXobum6tA==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=20.0.0"
+      },
+      "peerDependencies": {
+        "react": "^18.0 || ^19.0 || ^19.0.0-rc"
+      }
+    },
+    "node_modules/@react-email/head": {
+      "version": "0.0.13",
+      "resolved": "https://registry.npmjs.org/@react-email/head/-/head-0.0.13.tgz",
+      "integrity": "sha512-AJg6le/08Gz4tm+6MtKXqtNNyKHzmooOCdmtqmWxD7FxoAdU1eVcizhtQ0gcnVaY6ethEyE/hnEzQxt1zu5Kog==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=20.0.0"
+      },
+      "peerDependencies": {
+        "react": "^18.0 || ^19.0 || ^19.0.0-rc"
+      }
+    },
+    "node_modules/@react-email/heading": {
+      "version": "0.0.16",
+      "resolved": "https://registry.npmjs.org/@react-email/heading/-/heading-0.0.16.tgz",
+      "integrity": "sha512-jmsKnQm1ykpBzw4hCYHwBkt5pW2jScXffPeEH5ZRF5tZeF5b1pvlFTO9han7C0pCkZYo1kEvWiRtx69yfCIwuw==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=20.0.0"
+      },
+      "peerDependencies": {
+        "react": "^18.0 || ^19.0 || ^19.0.0-rc"
+      }
+    },
+    "node_modules/@react-email/hr": {
+      "version": "0.0.12",
+      "resolved": "https://registry.npmjs.org/@react-email/hr/-/hr-0.0.12.tgz",
+      "integrity": "sha512-TwmOmBDibavUQpXBxpmZYi2Iks/yeZOzFYh+di9EltMSnEabH8dMZXrl+pxNXzCgZ2XE8HY7VmUL65Lenfu5PA==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=20.0.0"
+      },
+      "peerDependencies": {
+        "react": "^18.0 || ^19.0 || ^19.0.0-rc"
+      }
+    },
+    "node_modules/@react-email/html": {
+      "version": "0.0.12",
+      "resolved": "https://registry.npmjs.org/@react-email/html/-/html-0.0.12.tgz",
+      "integrity": "sha512-KTShZesan+UsreU7PDUV90afrZwU5TLwYlALuCSU0OT+/U8lULNNbAUekg+tGwCnOfIKYtpDPKkAMRdYlqUznw==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=20.0.0"
+      },
+      "peerDependencies": {
+        "react": "^18.0 || ^19.0 || ^19.0.0-rc"
+      }
+    },
+    "node_modules/@react-email/img": {
+      "version": "0.0.12",
+      "resolved": "https://registry.npmjs.org/@react-email/img/-/img-0.0.12.tgz",
+      "integrity": "sha512-sRCpEARNVTf3FQhZOC+JTvu5r6ubiYWkT0ucYXg8ctkyi4G8QG+jgYPiNUqVeTLA2STOfmPM/nrk1nb84y6CPQ==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=20.0.0"
+      },
+      "peerDependencies": {
+        "react": "^18.0 || ^19.0 || ^19.0.0-rc"
+      }
+    },
+    "node_modules/@react-email/link": {
+      "version": "0.0.13",
+      "resolved": "https://registry.npmjs.org/@react-email/link/-/link-0.0.13.tgz",
+      "integrity": "sha512-lkWc/NjOcefRZMkQoSDDbuKBEBDES9aXnFEOuPH845wD3TxPwh+QTf0fStuzjoRLUZWpHnio4z7qGGRYusn/sw==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=20.0.0"
+      },
+      "peerDependencies": {
+        "react": "^18.0 || ^19.0 || ^19.0.0-rc"
+      }
+    },
+    "node_modules/@react-email/markdown": {
+      "version": "0.0.18",
+      "resolved": "https://registry.npmjs.org/@react-email/markdown/-/markdown-0.0.18.tgz",
+      "integrity": "sha512-gSuYK5fsMbGk87jDebqQ6fa2fKcWlkf2Dkva8kMONqLgGCq8/0d+ZQYMEJsdidIeBo3kmsnHZPrwdFB4HgjUXg==",
+      "license": "MIT",
+      "dependencies": {
+        "marked": "^15.0.12"
+      },
+      "engines": {
+        "node": ">=20.0.0"
+      },
+      "peerDependencies": {
+        "react": "^18.0 || ^19.0 || ^19.0.0-rc"
+      }
+    },
+    "node_modules/@react-email/preview": {
+      "version": "0.0.14",
+      "resolved": "https://registry.npmjs.org/@react-email/preview/-/preview-0.0.14.tgz",
+      "integrity": "sha512-aYK8q0IPkBXyMsbpMXgxazwHxYJxTrXrV95GFuu2HbEiIToMwSyUgb8HDFYwPqqfV03/jbwqlsXmFxsOd+VNaw==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=20.0.0"
+      },
+      "peerDependencies": {
+        "react": "^18.0 || ^19.0 || ^19.0.0-rc"
+      }
+    },
+    "node_modules/@react-email/render": {
+      "version": "2.0.5",
+      "resolved": "https://registry.npmjs.org/@react-email/render/-/render-2.0.5.tgz",
+      "integrity": "sha512-oAsSpY/vYt9ReDcRQDBLxENwCNAklkE6bvP5Kl9ZlmVr/RZpfhloJp8xc/OZki/YF2nisRRX50aEy8P9v3R5GA==",
+      "license": "MIT",
+      "dependencies": {
+        "html-to-text": "^9.0.5",
+        "prettier": "^3.5.3"
+      },
+      "engines": {
+        "node": ">=20.0.0"
+      },
+      "peerDependencies": {
+        "react": "^18.0 || ^19.0 || ^19.0.0-rc",
+        "react-dom": "^18.0 || ^19.0 || ^19.0.0-rc"
+      }
+    },
+    "node_modules/@react-email/row": {
+      "version": "0.0.13",
+      "resolved": "https://registry.npmjs.org/@react-email/row/-/row-0.0.13.tgz",
+      "integrity": "sha512-bYnOac40vIKCId7IkwuLAAsa3fKfSfqCvv6epJKmPE0JBuu5qI4FHFCl9o9dVpIIS08s/ub+Y/txoMt0dYziGw==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=20.0.0"
+      },
+      "peerDependencies": {
+        "react": "^18.0 || ^19.0 || ^19.0.0-rc"
+      }
+    },
+    "node_modules/@react-email/section": {
+      "version": "0.0.17",
+      "resolved": "https://registry.npmjs.org/@react-email/section/-/section-0.0.17.tgz",
+      "integrity": "sha512-qNl65ye3W0Rd5udhdORzTV9ezjb+GFqQQSae03NDzXtmJq6sqVXNWNiVolAjvJNypim+zGXmv6J9TcV5aNtE/w==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=20.0.0"
+      },
+      "peerDependencies": {
+        "react": "^18.0 || ^19.0 || ^19.0.0-rc"
+      }
+    },
+    "node_modules/@react-email/tailwind": {
+      "version": "2.0.7",
+      "resolved": "https://registry.npmjs.org/@react-email/tailwind/-/tailwind-2.0.7.tgz",
+      "integrity": "sha512-kGw80weVFXikcnCXbigTGXGWQ0MRCSYNCudcdkWxebkWYd0FG6/NPoN3V1p/u68/4+NxZwYPVi2fhnp0x23HdA==",
+      "license": "MIT",
+      "dependencies": {
+        "tailwindcss": "^4.1.18"
+      },
+      "engines": {
+        "node": ">=20.0.0"
+      },
+      "peerDependencies": {
+        "@react-email/body": ">=0",
+        "@react-email/button": ">=0",
+        "@react-email/code-block": ">=0",
+        "@react-email/code-inline": ">=0",
+        "@react-email/container": ">=0",
+        "@react-email/heading": ">=0",
+        "@react-email/hr": ">=0",
+        "@react-email/img": ">=0",
+        "@react-email/link": ">=0",
+        "@react-email/preview": ">=0",
+        "@react-email/text": ">=0",
+        "react": "^18.0 || ^19.0 || ^19.0.0-rc"
+      },
+      "peerDependenciesMeta": {
+        "@react-email/body": {
+          "optional": true
+        },
+        "@react-email/button": {
+          "optional": true
+        },
+        "@react-email/code-block": {
+          "optional": true
+        },
+        "@react-email/code-inline": {
+          "optional": true
+        },
+        "@react-email/container": {
+          "optional": true
+        },
+        "@react-email/heading": {
+          "optional": true
+        },
+        "@react-email/hr": {
+          "optional": true
+        },
+        "@react-email/img": {
+          "optional": true
+        },
+        "@react-email/link": {
+          "optional": true
+        },
+        "@react-email/preview": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/@react-email/tailwind/node_modules/tailwindcss": {
+      "version": "4.2.2",
+      "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.2.tgz",
+      "integrity": "sha512-KWBIxs1Xb6NoLdMVqhbhgwZf2PGBpPEiwOqgI4pFIYbNTfBXiKYyWoTsXgBQ9WFg/OlhnvHaY+AEpW7wSmFo2Q==",
+      "license": "MIT"
+    },
+    "node_modules/@react-email/text": {
+      "version": "0.1.6",
+      "resolved": "https://registry.npmjs.org/@react-email/text/-/text-0.1.6.tgz",
+      "integrity": "sha512-TYqkioRS45wTR5il3dYk/SbUjjEdhSwh9BtRNB99qNH1pXAwA45H7rAuxehiu8iJQJH0IyIr+6n62gBz9ezmsw==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=20.0.0"
+      },
+      "peerDependencies": {
+        "react": "^18.0 || ^19.0 || ^19.0.0-rc"
+      }
+    },
+    "node_modules/@selderee/plugin-htmlparser2": {
+      "version": "0.11.0",
+      "resolved": "https://registry.npmjs.org/@selderee/plugin-htmlparser2/-/plugin-htmlparser2-0.11.0.tgz",
+      "integrity": "sha512-P33hHGdldxGabLFjPPpaTxVolMrzrcegejx+0GxjrIb9Zv48D8yAIA/QTDR2dFl7Uz7urX8aX6+5bCZslr+gWQ==",
+      "license": "MIT",
+      "dependencies": {
+        "domhandler": "^5.0.3",
+        "selderee": "^0.11.0"
+      },
+      "funding": {
+        "url": "https://ko-fi.com/killymxi"
+      }
+    },
+    "node_modules/@stablelib/base64": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/@stablelib/base64/-/base64-1.0.1.tgz",
+      "integrity": "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==",
+      "license": "MIT"
+    },
     "node_modules/@swc/helpers": {
       "version": "0.5.15",
       "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz",
@@ -2022,6 +2378,15 @@
         "ms": "2.0.0"
       }
     },
+    "node_modules/deepmerge": {
+      "version": "4.3.1",
+      "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz",
+      "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
     "node_modules/depd": {
       "version": "2.0.0",
       "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
@@ -2065,6 +2430,61 @@
       "dev": true,
       "license": "MIT"
     },
+    "node_modules/dom-serializer": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz",
+      "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==",
+      "license": "MIT",
+      "dependencies": {
+        "domelementtype": "^2.3.0",
+        "domhandler": "^5.0.2",
+        "entities": "^4.2.0"
+      },
+      "funding": {
+        "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1"
+      }
+    },
+    "node_modules/domelementtype": {
+      "version": "2.3.0",
+      "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz",
+      "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==",
+      "funding": [
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/fb55"
+        }
+      ],
+      "license": "BSD-2-Clause"
+    },
+    "node_modules/domhandler": {
+      "version": "5.0.3",
+      "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz",
+      "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==",
+      "license": "BSD-2-Clause",
+      "dependencies": {
+        "domelementtype": "^2.3.0"
+      },
+      "engines": {
+        "node": ">= 4"
+      },
+      "funding": {
+        "url": "https://github.com/fb55/domhandler?sponsor=1"
+      }
+    },
+    "node_modules/domutils": {
+      "version": "3.2.2",
+      "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz",
+      "integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==",
+      "license": "BSD-2-Clause",
+      "dependencies": {
+        "dom-serializer": "^2.0.0",
+        "domelementtype": "^2.3.0",
+        "domhandler": "^5.0.3"
+      },
+      "funding": {
+        "url": "https://github.com/fb55/domutils?sponsor=1"
+      }
+    },
     "node_modules/dotenv": {
       "version": "16.6.1",
       "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz",
@@ -2129,6 +2549,18 @@
         "node": ">= 0.8"
       }
     },
+    "node_modules/entities": {
+      "version": "4.5.0",
+      "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
+      "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==",
+      "license": "BSD-2-Clause",
+      "engines": {
+        "node": ">=0.12"
+      },
+      "funding": {
+        "url": "https://github.com/fb55/entities?sponsor=1"
+      }
+    },
     "node_modules/es-define-property": {
       "version": "1.0.1",
       "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
@@ -2302,6 +2734,12 @@
         "node": ">= 6"
       }
     },
+    "node_modules/fast-sha256": {
+      "version": "1.3.0",
+      "resolved": "https://registry.npmjs.org/fast-sha256/-/fast-sha256-1.3.0.tgz",
+      "integrity": "sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==",
+      "license": "Unlicense"
+    },
     "node_modules/fastq": {
       "version": "1.20.1",
       "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz",
@@ -2553,6 +2991,41 @@
       "integrity": "sha512-E3a5VwgXimGHwpRGV+WxRTKeSp2DW5DI5MWv34ulL3t5UNmyJWCQ1KmLEHbYzcfThfXG8amBL+fCYPneGHC4VA==",
       "license": "Apache-2.0"
     },
+    "node_modules/html-to-text": {
+      "version": "9.0.5",
+      "resolved": "https://registry.npmjs.org/html-to-text/-/html-to-text-9.0.5.tgz",
+      "integrity": "sha512-qY60FjREgVZL03vJU6IfMV4GDjGBIoOyvuFdpBDIX9yTlDw0TjxVBQp+P8NvpdIXNJvfWBTNul7fsAQJq2FNpg==",
+      "license": "MIT",
+      "dependencies": {
+        "@selderee/plugin-htmlparser2": "^0.11.0",
+        "deepmerge": "^4.3.1",
+        "dom-serializer": "^2.0.0",
+        "htmlparser2": "^8.0.2",
+        "selderee": "^0.11.0"
+      },
+      "engines": {
+        "node": ">=14"
+      }
+    },
+    "node_modules/htmlparser2": {
+      "version": "8.0.2",
+      "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-8.0.2.tgz",
+      "integrity": "sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA==",
+      "funding": [
+        "https://github.com/fb55/htmlparser2?sponsor=1",
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/fb55"
+        }
+      ],
+      "license": "MIT",
+      "dependencies": {
+        "domelementtype": "^2.3.0",
+        "domhandler": "^5.0.3",
+        "domutils": "^3.0.1",
+        "entities": "^4.4.0"
+      }
+    },
     "node_modules/http-errors": {
       "version": "2.0.1",
       "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
@@ -2758,6 +3231,15 @@
         "safe-buffer": "^5.0.1"
       }
     },
+    "node_modules/leac": {
+      "version": "0.6.0",
+      "resolved": "https://registry.npmjs.org/leac/-/leac-0.6.0.tgz",
+      "integrity": "sha512-y+SqErxb8h7nE/fiEX07jsbuhrpO9lL8eca7/Y1nuWV2moNlXhyd59iDGcRf6moVyDMbmTNzL40SUyrFU/yDpg==",
+      "license": "MIT",
+      "funding": {
+        "url": "https://ko-fi.com/killymxi"
+      }
+    },
     "node_modules/lilconfig": {
       "version": "3.1.3",
       "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz",
@@ -2841,6 +3323,18 @@
         "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0-rc"
       }
     },
+    "node_modules/marked": {
+      "version": "15.0.12",
+      "resolved": "https://registry.npmjs.org/marked/-/marked-15.0.12.tgz",
+      "integrity": "sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA==",
+      "license": "MIT",
+      "bin": {
+        "marked": "bin/marked.js"
+      },
+      "engines": {
+        "node": ">= 18"
+      }
+    },
     "node_modules/math-intrinsics": {
       "version": "1.1.0",
       "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
@@ -3159,6 +3653,19 @@
         "node": ">= 0.8"
       }
     },
+    "node_modules/parseley": {
+      "version": "0.12.1",
+      "resolved": "https://registry.npmjs.org/parseley/-/parseley-0.12.1.tgz",
+      "integrity": "sha512-e6qHKe3a9HWr0oMRVDTRhKce+bRO8VGQR3NyVwcjwrbhMmFCX9KszEV35+rn4AdilFAq9VPxP/Fe1wC9Qjd2lw==",
+      "license": "MIT",
+      "dependencies": {
+        "leac": "^0.6.0",
+        "peberminta": "^0.9.0"
+      },
+      "funding": {
+        "url": "https://ko-fi.com/killymxi"
+      }
+    },
     "node_modules/parseurl": {
       "version": "1.3.3",
       "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
@@ -3181,6 +3688,15 @@
       "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==",
       "license": "MIT"
     },
+    "node_modules/peberminta": {
+      "version": "0.9.0",
+      "resolved": "https://registry.npmjs.org/peberminta/-/peberminta-0.9.0.tgz",
+      "integrity": "sha512-XIxfHpEuSJbITd1H3EeQwpcZbTLHc+VVr8ANI9t5sit565tsI4/xK3KWTUFE2e6QiangUkh3B0jihzmGnNrRsQ==",
+      "license": "MIT",
+      "funding": {
+        "url": "https://ko-fi.com/killymxi"
+      }
+    },
     "node_modules/picocolors": {
       "version": "1.1.1",
       "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@@ -3220,6 +3736,12 @@
         "node": ">= 6"
       }
     },
+    "node_modules/postal-mime": {
+      "version": "2.7.4",
+      "resolved": "https://registry.npmjs.org/postal-mime/-/postal-mime-2.7.4.tgz",
+      "integrity": "sha512-0WdnFQYUrPGGTFu1uOqD2s7omwua8xaeYGdO6rb88oD5yJ/4pPHDA4sdWqfD8wQVfCny563n/HQS7zTFft+f/g==",
+      "license": "MIT-0"
+    },
     "node_modules/postcss": {
       "version": "8.5.8",
       "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz",
@@ -3383,6 +3905,21 @@
       "dev": true,
       "license": "MIT"
     },
+    "node_modules/prettier": {
+      "version": "3.8.1",
+      "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.1.tgz",
+      "integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==",
+      "license": "MIT",
+      "bin": {
+        "prettier": "bin/prettier.cjs"
+      },
+      "engines": {
+        "node": ">=14"
+      },
+      "funding": {
+        "url": "https://github.com/prettier/prettier?sponsor=1"
+      }
+    },
     "node_modules/prisma": {
       "version": "5.22.0",
       "resolved": "https://registry.npmjs.org/prisma/-/prisma-5.22.0.tgz",
@@ -3403,6 +3940,15 @@
         "fsevents": "2.3.3"
       }
     },
+    "node_modules/prismjs": {
+      "version": "1.30.0",
+      "resolved": "https://registry.npmjs.org/prismjs/-/prismjs-1.30.0.tgz",
+      "integrity": "sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=6"
+      }
+    },
     "node_modules/process-nextick-args": {
       "version": "2.0.1",
       "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
@@ -3591,6 +4137,27 @@
         "node": ">=0.10.0"
       }
     },
+    "node_modules/resend": {
+      "version": "6.10.0",
+      "resolved": "https://registry.npmjs.org/resend/-/resend-6.10.0.tgz",
+      "integrity": "sha512-i7CwZpYj4Oho1RxsTpLcCUkO08+HiL4NXrm6jLJ2WzJ89UGI8eROSieLONJA3hnUrf1OYnCyfq5F6POnHUMv1Q==",
+      "license": "MIT",
+      "dependencies": {
+        "postal-mime": "2.7.4",
+        "svix": "1.88.0"
+      },
+      "engines": {
+        "node": ">=20"
+      },
+      "peerDependencies": {
+        "@react-email/render": "*"
+      },
+      "peerDependenciesMeta": {
+        "@react-email/render": {
+          "optional": true
+        }
+      }
+    },
     "node_modules/resolve": {
       "version": "1.22.11",
       "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz",
@@ -3699,6 +4266,18 @@
       "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==",
       "license": "MIT"
     },
+    "node_modules/selderee": {
+      "version": "0.11.0",
+      "resolved": "https://registry.npmjs.org/selderee/-/selderee-0.11.0.tgz",
+      "integrity": "sha512-5TF+l7p4+OsnP8BCCvSyZiSPc4x4//p5uPwK8TCnVPJYRmU2aYKMpOXvw8zM5a5JvuuCGN1jmsMwuU2W02ukfA==",
+      "license": "MIT",
+      "dependencies": {
+        "parseley": "^0.12.0"
+      },
+      "funding": {
+        "url": "https://ko-fi.com/killymxi"
+      }
+    },
     "node_modules/semver": {
       "version": "7.7.4",
       "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
@@ -3901,6 +4480,16 @@
         "node": ">=0.10.0"
       }
     },
+    "node_modules/standardwebhooks": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/standardwebhooks/-/standardwebhooks-1.0.0.tgz",
+      "integrity": "sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg==",
+      "license": "MIT",
+      "dependencies": {
+        "@stablelib/base64": "^1.0.0",
+        "fast-sha256": "^1.3.0"
+      }
+    },
     "node_modules/statuses": {
       "version": "2.0.2",
       "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
@@ -4036,6 +4625,29 @@
         "url": "https://github.com/sponsors/ljharb"
       }
     },
+    "node_modules/svix": {
+      "version": "1.88.0",
+      "resolved": "https://registry.npmjs.org/svix/-/svix-1.88.0.tgz",
+      "integrity": "sha512-vm/JrrUd3bVyBE+3L33TIyVSs8gS5fYx7lrISvKlDJXTYX1ACH4REX8P1tHxsSKoZi/rvifM1t0XRc5Vc45THw==",
+      "license": "MIT",
+      "dependencies": {
+        "standardwebhooks": "1.0.0",
+        "uuid": "^10.0.0"
+      }
+    },
+    "node_modules/svix/node_modules/uuid": {
+      "version": "10.0.0",
+      "resolved": "https://registry.npmjs.org/uuid/-/uuid-10.0.0.tgz",
+      "integrity": "sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==",
+      "funding": [
+        "https://github.com/sponsors/broofa",
+        "https://github.com/sponsors/ctavan"
+      ],
+      "license": "MIT",
+      "bin": {
+        "uuid": "dist/bin/uuid"
+      }
+    },
     "node_modules/tailwind-merge": {
       "version": "2.6.1",
       "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-2.6.1.tgz",
@@ -4419,6 +5031,7 @@
       "version": "0.1.0",
       "dependencies": {
         "@prisma/client": "^5.22.0",
+        "@react-email/components": "^1.0.11",
         "bcryptjs": "^2.4.3",
         "cookie-parser": "^1.4.7",
         "cors": "^2.8.5",
@@ -4427,6 +5040,7 @@
         "fluent-ffmpeg": "^2.1.3",
         "jsonwebtoken": "^9.0.2",
         "multer": "^1.4.5-lts.1",
+        "resend": "^6.10.0",
         "uuid": "^11.0.5"
       },
       "devDependencies": {

+ 2 - 0
packages/api/package.json

@@ -13,6 +13,7 @@
   },
   "dependencies": {
     "@prisma/client": "^5.22.0",
+    "@react-email/components": "^1.0.11",
     "bcryptjs": "^2.4.3",
     "cookie-parser": "^1.4.7",
     "cors": "^2.8.5",
@@ -21,6 +22,7 @@
     "fluent-ffmpeg": "^2.1.3",
     "jsonwebtoken": "^9.0.2",
     "multer": "^1.4.5-lts.1",
+    "resend": "^6.10.0",
     "uuid": "^11.0.5"
   },
   "devDependencies": {

+ 23 - 23
packages/api/prisma/schema.prisma

@@ -23,11 +23,11 @@ model User {
   createdAt     DateTime  @default(now())
   updatedAt     DateTime  @updatedAt
 
-  memberships       ProjectMember[]
+  memberships       ProjectMember[] @relation("ProjectMembers")
   comments          Comment[]
-  projects          Project[]    // projects where this user is the owner
-  resolvedComments  Comment[]    @relation("ResolvedBy")
-  requestedComments Comment[]    @relation("RequestedBy")
+  projects          Project[]       @relation("ProjectOwner")
+  resolvedComments  Comment[]      @relation("ResolvedBy")
+  requestedComments Comment[]      @relation("RequestedBy")
   assets            Asset[]
 }
 
@@ -40,27 +40,27 @@ model Project {
   updatedAt   DateTime @updatedAt
 
   assets      Asset[]
-  members     ProjectMember[]
+  members     ProjectMember[] @relation("ProjectMembers")
   invitations Invitation[]
-  owner       User     @relation(fields: [ownerId], references: [id])
+  owner       User     @relation("ProjectOwner", fields: [ownerId], references: [id])
 }
 
 model SiteSetting {
-  id       String @id @default(cuid())
-  name     String @unique
-  value    String
+  id    String @id @default(cuid())
+  name  String @unique
+  value String
 }
 
 model ProjectMember {
-  id         String @id @default(cuid())
-  userId     String
-  projectId  String
-  role       Role   @default(REVIEWER)
-  joinedAt   DateTime @default(now())
-  invitedBy  String?    // userId who sent the invite
+  id        String   @id @default(cuid())
+  userId    String
+  projectId String
+  role      Role     @default(REVIEWER)
+  joinedAt  DateTime @default(now())
+  invitedBy String?    // userId who sent the invite
 
-  user    User    @relation(fields: [userId], references: [id], onDelete: Cascade)
-  project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
+  user    User    @relation("ProjectMembers", fields: [userId], references: [id], onDelete: Cascade)
+  project Project @relation("ProjectMembers", fields: [projectId], references: [id], onDelete: Cascade)
 
   @@unique([userId, projectId])
   @@index([projectId])
@@ -94,7 +94,7 @@ model Asset {
 }
 
 model Comment {
-  id           String         @id @default(cuid())
+  id            String         @id @default(cuid())
   assetId      String
   userId       String
   content      String
@@ -110,11 +110,11 @@ model Comment {
   createdAt    DateTime       @default(now())
   updatedAt    DateTime       @updatedAt
 
-  asset      Asset     @relation(fields: [assetId], references: [id], onDelete: Cascade)
-  user       User      @relation(fields: [userId], references: [id], onDelete: Cascade)
-  parent     Comment?  @relation("Replies", fields: [parentId], references: [id], onDelete: Cascade)
-  replies    Comment[] @relation("Replies")
-  resolvedBy User?     @relation("ResolvedBy", fields: [resolvedById], references: [id])
+  asset       Asset     @relation(fields: [assetId], references: [id], onDelete: Cascade)
+  user        User      @relation(fields: [userId], references: [id], onDelete: Cascade)
+  parent      Comment?  @relation("Replies", fields: [parentId], references: [id], onDelete: Cascade)
+  replies     Comment[] @relation("Replies")
+  resolvedBy  User?     @relation("ResolvedBy", fields: [resolvedById], references: [id])
   requestedBy User?    @relation("RequestedBy", fields: [requestedById], references: [id])
 }
 

+ 248 - 0
packages/api/src/lib/email.ts

@@ -0,0 +1,248 @@
+import { Resend } from 'resend';
+
+// Lazily initialized — only instantiated when RESEND_API_KEY is set
+let _resend: Resend | null = null;
+function getResend(): Resend | null {
+  if (!process.env.RESEND_API_KEY) return null;
+  if (!_resend) _resend = new Resend(process.env.RESEND_API_KEY);
+  return _resend;
+}
+
+const FROM = process.env.EMAIL_FROM || 'VidReview <noreply@vid.k9tech.space>';
+const FRONTEND_URL = process.env.FRONTEND_URL || process.env.NEXT_PUBLIC_API_URL?.replace('/api', '') || 'http://localhost:3000';
+
+function buildInviteHtml(invite: {
+  to: string;
+  projectName?: string | null;
+  role: string;
+  expiresDays: number;
+  inviteUrl: string;
+  type: 'WORKSPACE' | 'PROJECT';
+}): string {
+  const isWorkspace = invite.type === 'WORKSPACE';
+  const headline = isWorkspace
+    ? "You've been invited to join VidReview"
+    : `You've been invited to collaborate on "${invite.projectName}"`;
+
+  const roleLabel = invite.role.charAt(0) + invite.role.slice(1).toLowerCase();
+
+  return `<!DOCTYPE html>
+<html lang="en">
+<head>
+  <meta charset="UTF-8" />
+  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
+  <title>${headline}</title>
+</head>
+<body style="margin:0;padding:0;background:#0A0B14;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;">
+  <table width="100%" cellpadding="0" cellspacing="0" style="background:#0A0B14;padding:40px 16px;">
+    <tr>
+      <td align="center">
+        <table width="520" cellpadding="0" cellspacing="0" style="max-width:520px;width:100%;">
+
+          <!-- Logo -->
+          <tr>
+            <td align="center" style="padding-bottom:32px;">
+              <span style="font-size:22px;font-weight:700;color:#FFFFFF;letter-spacing:-0.3px;">
+                VidReview
+              </span>
+            </td>
+          </tr>
+
+          <!-- Card -->
+          <tr>
+            <td style="background:#161826;border-radius:16px;padding:40px;border:1px solid rgba(255,255,255,0.08);">
+
+              <!-- Icon -->
+              <tr>
+                <td align="center" style="padding-bottom:24px;">
+                  <div style="width:56px;height:56px;background:rgba(99,102,241,0.15);border-radius:14px;display:inline-flex;align-items:center;justify-content:center;">
+                    <div style="width:28px;height:28px;background:#6366F1;border-radius:8px;display:flex;align-items:center;justify-content:center;">
+                      <div style="width:12px;height:12px;background:white;border-radius:50%;"></div>
+                    </div>
+                  </div>
+                </td>
+              </tr>
+
+              <!-- Headline -->
+              <tr>
+                <td align="center" style="padding-bottom:12px;">
+                  <h1 style="margin:0;font-size:22px;font-weight:700;color:#FFFFFF;line-height:1.3;">
+                    ${headline}
+                  </h1>
+                </td>
+              </tr>
+
+              <!-- Description -->
+              <tr>
+                <td align="center" style="padding-bottom:28px;">
+                  <p style="margin:0;font-size:14px;color:#9CA3AF;line-height:1.6;">
+                    ${isWorkspace
+                      ? 'An admin has invited you to join the workspace. Create your account to get started and collaborate with your team.'
+                      : `You've been invited as <strong style="color:#E2E8F0;">${roleLabel}</strong> on this project.`}
+                  </p>
+                </td>
+              </tr>
+
+              <!-- CTA Button -->
+              <tr>
+                <td align="center" style="padding-bottom:28px;">
+                  <a href="${invite.inviteUrl}"
+                     style="display:inline-block;background:#6366F1;color:#FFFFFF;font-size:14px;font-weight:600;padding:14px 32px;border-radius:10px;text-decoration:none;letter-spacing:0.2px;">
+                    Accept Invitation
+                  </a>
+                </td>
+              </tr>
+
+              <!-- Details box -->
+              <tr>
+                <td style="background:rgba(99,102,241,0.08);border-radius:10px;padding:16px 20px;border:1px solid rgba(99,102,241,0.15);margin-bottom:24px;">
+                  <table width="100%" cellpadding="0" cellspacing="0">
+                    <tr>
+                      <td style="padding:4px 0;">
+                        <span style="font-size:12px;color:#6B7280;">Invited email</span>
+                      </td>
+                      <td align="right" style="padding:4px 0;">
+                        <span style="font-size:12px;color:#9CA3AF;">${invite.to}</span>
+                      </td>
+                    </tr>
+                    ${!isWorkspace ? `
+                    <tr>
+                      <td style="padding:4px 0;">
+                        <span style="font-size:12px;color:#6B7280;">Role</span>
+                      </td>
+                      <td align="right" style="padding:4px 0;">
+                        <span style="font-size:12px;color:#9CA3AF;">${roleLabel}</span>
+                      </td>
+                    </tr>` : ''}
+                    <tr>
+                      <td style="padding:4px 0;">
+                        <span style="font-size:12px;color:#6B7280;">Expires in</span>
+                      </td>
+                      <td align="right" style="padding:4px 0;">
+                        <span style="font-size:12px;color:#9CA3AF;">${invite.expiresDays} days</span>
+                      </td>
+                    </tr>
+                  </table>
+                </td>
+              </tr>
+
+              <!-- Link fallback -->
+              <tr>
+                <td align="center">
+                  <p style="margin:0 0 8px;font-size:12px;color:#6B7280;">
+                    Or copy and paste this link into your browser:
+                  </p>
+                  <a href="${invite.inviteUrl}"
+                     style="font-size:11px;color:#6366F1;word-break:break-all;text-decoration:none;">
+                    ${invite.inviteUrl}
+                  </a>
+                </td>
+              </tr>
+
+            </td>
+          </tr>
+
+          <!-- Footer -->
+          <tr>
+            <td align="center" style="padding-top:28px;">
+              <p style="margin:0;font-size:12px;color:#4B5563;line-height:1.5;">
+                You're receiving this because an admin sent you an invite.<br/>
+                VidReview — vid.k9tech.space
+              </p>
+            </td>
+          </tr>
+
+        </table>
+      </td>
+    </tr>
+  </table>
+</body>
+</html>`;
+}
+
+function buildInviteText(invite: {
+  to: string;
+  projectName?: string | null;
+  role: string;
+  expiresDays: number;
+  inviteUrl: string;
+  type: 'WORKSPACE' | 'PROJECT';
+}): string {
+  const isWorkspace = invite.type === 'WORKSPACE';
+  const headline = isWorkspace
+    ? "You've been invited to join VidReview"
+    : `You've been invited to collaborate on "${invite.projectName}"`;
+  const roleLabel = invite.role.charAt(0) + invite.role.slice(1).toLowerCase();
+
+  return `${headline}
+
+${isWorkspace
+  ? 'An admin has invited you to join the workspace. Create your account to get started.'
+  : `You've been invited as ${roleLabel}.`}
+
+Accept your invitation:
+${invite.inviteUrl}
+
+Invited email: ${invite.to}
+${!isWorkspace ? `Role: ${roleLabel}\n` : ''}Expires in: ${invite.expiresDays} days
+
+VidReview — vid.k9tech.space`;
+}
+
+/** Skip email for .local domains (dev/test addresses) */
+function shouldSkipEmail(email: string): boolean {
+  return email.toLowerCase().endsWith('.local');
+}
+
+export interface SendInviteEmailOptions {
+  to: string;
+  projectName?: string | null;
+  role: string;
+  expiresDays: number;
+  inviteUrl: string;
+  type: 'WORKSPACE' | 'PROJECT';
+}
+
+export async function sendInviteEmail(opts: SendInviteEmailOptions): Promise<void> {
+  if (shouldSkipEmail(opts.to)) {
+    console.log(`[email] Skipping .local address: ${opts.to}`);
+    return;
+  }
+
+  const apiKey = process.env.RESEND_API_KEY;
+  if (!apiKey) {
+    console.warn('[email] RESEND_API_KEY not set — skipping email send');
+    return;
+  }
+
+  const client = getResend();
+  if (!client) {
+    console.warn('[email] Resend client unavailable — skipping email send');
+    return;
+  }
+
+  const html = buildInviteHtml(opts);
+  const text = buildInviteText(opts);
+  const subject = opts.type === 'WORKSPACE'
+    ? "You've been invited to join VidReview"
+    : `You've been invited to collaborate on "${opts.projectName}"`;
+
+  try {
+    const { error } = await client.emails.send({
+      from: FROM,
+      to: opts.to,
+      subject,
+      html,
+      text,
+    });
+
+    if (error) {
+      console.error('[email] Resend error:', error);
+    } else {
+      console.log(`[email] Invite email sent to ${opts.to}`);
+    }
+  } catch (err) {
+    console.error('[email] Failed to send invite email:', err);
+    // Don't throw — email failure shouldn't break invite creation
+  }
+}

+ 18 - 11
packages/api/src/routes/auth.ts

@@ -58,9 +58,8 @@ router.post('/register', async (req: Request, res: Response) => {
     // - Workspace invite (type=WORKSPACE, projectId=null) → MEMBER
     // - Project invite (type=PROJECT, projectId set) → PROJECT_USER
     const invite: { type?: string; projectId?: string | null } | null = (req as any)._invite ?? null;
-    const globalRole = invite
-      ? (invite.type === 'WORKSPACE' || invite.projectId === null ? 'MEMBER' : 'PROJECT_USER')
-      : 'MEMBER' as const;
+    // projectId=null → workspace invite → MEMBER; projectId set → project invite → PROJECT_USER
+    const globalRole: 'MEMBER' | 'PROJECT_USER' = invite?.projectId === null ? 'MEMBER' : 'PROJECT_USER';
 
     const user = await prisma.user.create({
       data: {
@@ -87,22 +86,28 @@ router.post('/register', async (req: Request, res: Response) => {
     for (const invite of pendingInvites) {
       // Workspace invite (projectId=null) — no project membership to create
       if (invite.projectId === null) {
+        await prisma.invitation.update({
+          where: { id: invite.id },
+          data: { status: 'ACCEPTED' },
+        });
         continue;
       }
+      // TypeScript narrowing doesn't carry through prisma queries
+      const pid = invite.projectId;
       const existingMember = await prisma.projectMember.findFirst({
-        where: { projectId: invite.projectId, userId: user.id },
+        where: { projectId: pid, userId: user.id },
       });
       if (!existingMember) {
         await prisma.projectMember.create({
           data: {
             userId: user.id,
-            projectId: invite.projectId,
+            projectId: pid,
             role: invite.role,
-            invitedBy: invite.invitedBy,
+            invitedBy: invite.invitedBy ?? undefined,
           },
         });
         const project = await prisma.project.findUnique({
-          where: { id: invite.projectId },
+          where: { id: pid },
           select: { id: true, name: true },
         });
         if (project) acceptedProjects.push({ projectId: project.id, projectName: project.name });
@@ -170,20 +175,22 @@ router.post('/login', async (req: Request, res: Response) => {
         });
         continue;
       }
+      // TypeScript narrowing doesn't work through prisma queries — assign to let
+      const pid = invite.projectId;
       const existingMember = await prisma.projectMember.findFirst({
-        where: { projectId: invite.projectId, userId: user.id },
+        where: { projectId: pid, userId: user.id },
       });
       if (!existingMember) {
         await prisma.projectMember.create({
           data: {
             userId: user.id,
-            projectId: invite.projectId,
+            projectId: pid,
             role: invite.role,
-            invitedBy: invite.invitedBy,
+            invitedBy: invite.invitedBy ?? undefined,
           },
         });
         const project = await prisma.project.findUnique({
-          where: { id: invite.projectId },
+          where: { id: pid },
           select: { id: true, name: true },
         });
         if (project) acceptedProjects.push({ projectId: project.id, projectName: project.name });

+ 35 - 0
packages/api/src/routes/invitations.ts

@@ -2,6 +2,7 @@ import { Router, Request, Response } from 'express';
 import { randomBytes } from 'crypto';
 import { prisma } from '../lib/prisma';
 import { authMiddleware, optionalAuth } from '../lib/auth';
+import { sendInviteEmail } from '../lib/email';
 
 const router = Router();
 const INVITE_EXPIRY_DAYS = 7;
@@ -270,6 +271,18 @@ router.post('/project/:projectId', authMiddleware, async (req: Request, res: Res
 
     // Return full invite URL
     const inviteUrl = buildInviteUrl(token);
+
+    // Send invite email (skipped for .local domains or if RESEND_API_KEY not set)
+    const project = await prisma.project.findUnique({ where: { id: projectId }, select: { name: true } });
+    await sendInviteEmail({
+      to: email,
+      projectName: project?.name,
+      role,
+      expiresDays: INVITE_EXPIRY_DAYS,
+      inviteUrl,
+      type: 'PROJECT',
+    });
+
     res.status(201).json({ invitation, inviteUrl });
   } catch (err) {
     console.error('Create invitation error:', err);
@@ -327,6 +340,17 @@ router.post('/workspace', authMiddleware, async (req: Request, res: Response) =>
     });
 
     const inviteUrl = buildInviteUrl(token);
+
+    // Send invite email (skipped for .local domains or if RESEND_API_KEY not set)
+    await sendInviteEmail({
+      to: email,
+      projectName: null,
+      role: 'MEMBER',
+      expiresDays: INVITE_EXPIRY_DAYS,
+      inviteUrl,
+      type: 'WORKSPACE',
+    });
+
     res.status(201).json({ invitation, inviteUrl });
 
   } catch (err) {
@@ -403,6 +427,17 @@ router.post('/', authMiddleware, async (req: Request, res: Response) => {
     });
 
     const inviteUrl = buildInviteUrl(token);
+
+    // Send invite email (skipped for .local domains or if RESEND_API_KEY not set)
+    await sendInviteEmail({
+      to: email,
+      projectName: project.name,
+      role,
+      expiresDays: INVITE_EXPIRY_DAYS,
+      inviteUrl,
+      type: 'PROJECT',
+    });
+
     res.status(201).json({ invitation, inviteUrl });
   } catch (err) {
     console.error('Admin invite error:', err);