first commit

This commit is contained in:
debu-2 2026-10-03 21:14:44 +05:30
commit cce9a8c9ba
73 changed files with 8665 additions and 0 deletions

3
frontend/.dockerignore Normal file
View file

@ -0,0 +1,3 @@
node_modules/
dist/
*.log

17
frontend/Dockerfile Normal file
View file

@ -0,0 +1,17 @@
FROM node:24-alpine AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY index.html ./
COPY src ./src
RUN npm run build
FROM nginx:1.29-alpine
COPY nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=build /app/dist /usr/share/nginx/html
EXPOSE 80
HEALTHCHECK --interval=10s --timeout=3s --retries=3 \
CMD wget -q --spider http://127.0.0.1/ || exit 1

14
frontend/index.html Normal file
View file

@ -0,0 +1,14 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#060622" />
<meta name="description" content="Agmail is the focused inbox built for people and their AI agents." />
<title>Agmail — An email for your AI agent</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>

27
frontend/nginx.conf Normal file
View file

@ -0,0 +1,27 @@
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
location /api/ {
proxy_pass http://backend:8000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location = /health {
proxy_pass http://backend:8000/health;
}
location = /index.html {
add_header Cache-Control "no-cache, no-store, must-revalidate";
try_files $uri =404;
}
location / {
try_files $uri $uri/ /index.html;
}
}

763
frontend/package-lock.json generated Normal file
View file

@ -0,0 +1,763 @@
{
"name": "agmail-frontend",
"version": "0.1.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "agmail-frontend",
"version": "0.1.0",
"dependencies": {
"@vitejs/plugin-react": "latest",
"lucide-react": "latest",
"react": "latest",
"react-dom": "latest",
"vite": "latest"
},
"devDependencies": {}
},
"node_modules/@oxc-project/types": {
"version": "0.143.0",
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.143.0.tgz",
"integrity": "sha512-u6JZdLBTLotrNC9Vd6vPssINdzcCzleKAH6EJKImQb7GtYvX5keN2dxkoK44stCc4tffE6QQRtZTXVSzsLUlWA==",
"funding": {
"url": "https://github.com/sponsors/Boshen"
}
},
"node_modules/@rolldown/binding-android-arm64": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.3.tgz",
"integrity": "sha512-zrJtHDcaZJ1Fp7xf4hNl+7seH9Cn/N5TwLYkhgXREtBwAd/jaqW3uqeHxpDugJLVICWg4eW44kOQEGJ1r6jCGw==",
"cpu": [
"arm64"
],
"optional": true,
"os": [
"android"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-darwin-arm64": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.3.tgz",
"integrity": "sha512-ieIiibVCp0tX7TLu2cafoNPv8wJyYi01ekXpbf8q2j7F4rGAhhXb/eQh7ge9DRBY78GwmRQtvjZDux7EDbA8kA==",
"cpu": [
"arm64"
],
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-darwin-x64": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.3.tgz",
"integrity": "sha512-Zh9tCon19eDXJoihx0rqKhMUlMYqzwj3aPsSuHmI4RWZh62dWUL+DJN4C5YQya5TcQBJU/Fe8+rY0jhXTQITqA==",
"cpu": [
"x64"
],
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-freebsd-x64": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.3.tgz",
"integrity": "sha512-nGbJWewA1wrXXZiQhjAT5rhibGfns5ZNkDVqxsO6zJ3f3YvpoDNNmGMSbbhLuXKjNScaBJVOAboztAWVespQMg==",
"cpu": [
"x64"
],
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-linux-arm-gnueabihf": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.3.tgz",
"integrity": "sha512-QNniJr5Kml0kDEB98jiDOJjXNroxIIi0IXIbdYzY26Xt1pVbeP62+KnoIZLwirOymX/0jDk/2gI/bNUv7A7OIw==",
"cpu": [
"arm"
],
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-linux-arm64-gnu": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.3.tgz",
"integrity": "sha512-TkqEAcmmvH3I/q4114NB4RVt6241Dao48pF45uLcFGrwAaIn0iITgTAKP/dLjbN0R4buJjGb91+UHSoFmpgIWw==",
"cpu": [
"arm64"
],
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-linux-arm64-musl": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.3.tgz",
"integrity": "sha512-NHqjnxpsndf4MPymxteFAWHHfkTL8HjWh1KB7z23ofZ6QO2euONuxDXjat69dKZRALnGypg8k8SsK8vZJoXv1Q==",
"cpu": [
"arm64"
],
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-linux-ppc64-gnu": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.3.tgz",
"integrity": "sha512-6tbrbwfz5GB9DQ4Jwo6hy9v+vR31xZlvzZ6n5Xut6Hhx5PvrA9q/HsK8KMaYQp063iqZGXwNvZtYNLD7EM/x0w==",
"cpu": [
"ppc64"
],
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-linux-s390x-gnu": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.3.tgz",
"integrity": "sha512-oyuXxXmoZHjXC917IAPFAAv4wWAa0cM9afk8nx1+9/jNNOX1uPf8yDA6p7G0RypOfw/X0PQt5IfoquY1um+zSg==",
"cpu": [
"s390x"
],
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-linux-x64-gnu": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.3.tgz",
"integrity": "sha512-TytMwF2KVGqP2tgd0I1OY0PAv78dZRAYcF5ssDzjM34SUXCED3uXvSd5+lHoC0bTD6eEdFz7LdQNCO1y0oVk9w==",
"cpu": [
"x64"
],
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-linux-x64-musl": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.3.tgz",
"integrity": "sha512-/E9m3qstrJFVPoULV25mVQblSNExY2+kBsYe4sy0Tn0yOOgJ8wZbZt3KnRbF/XeU2Gl1STKUQnDNTqhIE5MD4A==",
"cpu": [
"x64"
],
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-openharmony-arm64": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.3.tgz",
"integrity": "sha512-Kr0OcsoQI816i6HOl3vFHpd1K0eZyh76zgfj4c1nTyaTsd5r2Mj1lwM4R90y/qaCfmTn9eHy0SKwi98eitRxug==",
"cpu": [
"arm64"
],
"optional": true,
"os": [
"openharmony"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-win32-arm64-msvc": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.3.tgz",
"integrity": "sha512-hOtMwTqnME+/gJcH/PCZ0wn0zPUjiWOgkHpxbSJpfGKMezHltx1S7/k1SitzVa7Ww2cqrDDaFbZEhcJZO8o+Jw==",
"cpu": [
"arm64"
],
"optional": true,
"os": [
"win32"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-win32-x64-msvc": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.3.tgz",
"integrity": "sha512-ekcqMMkI2PlhYnfzQnB/cEdYUVVJViWvoUyLrbzgDoi3Snfc1mVBwdnc306ufA5ejy8JSPjT2RlW1nQSjW7efg==",
"cpu": [
"x64"
],
"optional": true,
"os": [
"win32"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/pluginutils": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz",
"integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw=="
},
"node_modules/@vitejs/plugin-react": {
"version": "6.0.5",
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.5.tgz",
"integrity": "sha512-BOVzne/NL162sMdResB25mUv+vWMF5NoAjNf09TeGlE7ZpszZWSD3winycicLJw72yeVsoCn/2kOhEuCvEShMA==",
"dependencies": {
"@rolldown/pluginutils": "^1.0.1"
},
"engines": {
"node": "^20.19.0 || >=22.12.0"
},
"peerDependencies": {
"@rolldown/plugin-babel": "^0.1.7 || ^0.2.0",
"babel-plugin-react-compiler": "^1.0.0",
"vite": "^8.0.0"
},
"peerDependenciesMeta": {
"@rolldown/plugin-babel": {
"optional": true
},
"babel-plugin-react-compiler": {
"optional": true
}
}
},
"node_modules/detect-libc": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
"engines": {
"node": ">=8"
}
},
"node_modules/fdir": {
"version": "6.5.0",
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
"integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
"engines": {
"node": ">=12.0.0"
},
"peerDependencies": {
"picomatch": "^3 || ^4"
},
"peerDependenciesMeta": {
"picomatch": {
"optional": true
}
}
},
"node_modules/fsevents": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
"hasInstallScript": true,
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/lightningcss": {
"version": "1.33.0",
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz",
"integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==",
"dependencies": {
"detect-libc": "^2.0.3"
},
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
},
"optionalDependencies": {
"lightningcss-android-arm64": "1.33.0",
"lightningcss-darwin-arm64": "1.33.0",
"lightningcss-darwin-x64": "1.33.0",
"lightningcss-freebsd-x64": "1.33.0",
"lightningcss-linux-arm-gnueabihf": "1.33.0",
"lightningcss-linux-arm64-gnu": "1.33.0",
"lightningcss-linux-arm64-musl": "1.33.0",
"lightningcss-linux-x64-gnu": "1.33.0",
"lightningcss-linux-x64-musl": "1.33.0",
"lightningcss-win32-arm64-msvc": "1.33.0",
"lightningcss-win32-x64-msvc": "1.33.0"
}
},
"node_modules/lightningcss-android-arm64": {
"version": "1.33.0",
"resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz",
"integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==",
"cpu": [
"arm64"
],
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-darwin-arm64": {
"version": "1.33.0",
"resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz",
"integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==",
"cpu": [
"arm64"
],
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-darwin-x64": {
"version": "1.33.0",
"resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz",
"integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==",
"cpu": [
"x64"
],
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-freebsd-x64": {
"version": "1.33.0",
"resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz",
"integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==",
"cpu": [
"x64"
],
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-linux-arm-gnueabihf": {
"version": "1.33.0",
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz",
"integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==",
"cpu": [
"arm"
],
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-linux-arm64-gnu": {
"version": "1.33.0",
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz",
"integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==",
"cpu": [
"arm64"
],
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-linux-arm64-musl": {
"version": "1.33.0",
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz",
"integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==",
"cpu": [
"arm64"
],
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-linux-x64-gnu": {
"version": "1.33.0",
"resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz",
"integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==",
"cpu": [
"x64"
],
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-linux-x64-musl": {
"version": "1.33.0",
"resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz",
"integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==",
"cpu": [
"x64"
],
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-win32-arm64-msvc": {
"version": "1.33.0",
"resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz",
"integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==",
"cpu": [
"arm64"
],
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-win32-x64-msvc": {
"version": "1.33.0",
"resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz",
"integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==",
"cpu": [
"x64"
],
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lucide-react": {
"version": "1.29.0",
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.29.0.tgz",
"integrity": "sha512-Xs9QFG5+9sNX04MdKVT4++umA+hJ2qsJVlRlRWHQ7qZobXgMiNHSpZ5eZm8JUoGCdNyoEdXoEwa8HVr0DNjOQg==",
"peerDependencies": {
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/nanoid": {
"version": "3.3.17",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.17.tgz",
"integrity": "sha512-xQLf0A3HOMlgHq0n247/LRuAOYmB7dXJ/DvAxGvsSBij45XtBSmQycu+F8ODbHwns/XyFZagyL1+J0Offw1E0g==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"bin": {
"nanoid": "bin/nanoid.cjs"
},
"engines": {
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
}
},
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="
},
"node_modules/picomatch": {
"version": "4.0.5",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz",
"integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/postcss": {
"version": "8.5.26",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz",
"integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==",
"funding": [
{
"type": "opencollective",
"url": "https://opencollective.com/postcss/"
},
{
"type": "tidelift",
"url": "https://tidelift.com/funding/github/npm/postcss"
},
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"dependencies": {
"nanoid": "^3.3.17",
"picocolors": "^1.1.1",
"source-map-js": "^1.2.1"
},
"engines": {
"node": "^10 || ^12 || >=14"
}
},
"node_modules/react": {
"version": "19.2.8",
"resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz",
"integrity": "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/react-dom": {
"version": "19.2.8",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.8.tgz",
"integrity": "sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==",
"dependencies": {
"scheduler": "^0.27.0"
},
"peerDependencies": {
"react": "^19.2.8"
}
},
"node_modules/rolldown": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.3.tgz",
"integrity": "sha512-rn9wpmxplLf7NLNyCk9FyWh3FM43DbY8jOzCdEPzH7uflhTftRbCEpqi6Ly2osgoU8OwObtmavMbWLaWy4LX7A==",
"dependencies": {
"@oxc-project/types": "=0.143.0",
"@rolldown/pluginutils": "^1.0.0"
},
"bin": {
"rolldown": "bin/cli.mjs"
},
"engines": {
"node": "^20.19.0 || >=22.12.0"
},
"optionalDependencies": {
"@rolldown/binding-android-arm64": "1.2.3",
"@rolldown/binding-darwin-arm64": "1.2.3",
"@rolldown/binding-darwin-x64": "1.2.3",
"@rolldown/binding-freebsd-x64": "1.2.3",
"@rolldown/binding-linux-arm-gnueabihf": "1.2.3",
"@rolldown/binding-linux-arm64-gnu": "1.2.3",
"@rolldown/binding-linux-arm64-musl": "1.2.3",
"@rolldown/binding-linux-ppc64-gnu": "1.2.3",
"@rolldown/binding-linux-s390x-gnu": "1.2.3",
"@rolldown/binding-linux-x64-gnu": "1.2.3",
"@rolldown/binding-linux-x64-musl": "1.2.3",
"@rolldown/binding-openharmony-arm64": "1.2.3",
"@rolldown/binding-win32-arm64-msvc": "1.2.3",
"@rolldown/binding-win32-x64-msvc": "1.2.3"
}
},
"node_modules/scheduler": {
"version": "0.27.0",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
"integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="
},
"node_modules/source-map-js": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
"integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/tinyglobby": {
"version": "0.2.17",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
"integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==",
"dependencies": {
"fdir": "^6.5.0",
"picomatch": "^4.0.4"
},
"engines": {
"node": ">=12.0.0"
},
"funding": {
"url": "https://github.com/sponsors/SuperchupuDev"
}
},
"node_modules/vite": {
"version": "8.2.0",
"resolved": "https://registry.npmjs.org/vite/-/vite-8.2.0.tgz",
"integrity": "sha512-pn+CFpM0lwDeKwmOq1ZaBK/9sjorZcgqxki6MbY/jPEVd9vichIlmlD4HmQ5wdP5EgqQCFRaACBxMC7uEGc6lQ==",
"dependencies": {
"lightningcss": "^1.33.0",
"picomatch": "^4.0.5",
"postcss": "^8.5.23",
"rolldown": "~1.2.0",
"tinyglobby": "^0.2.17"
},
"bin": {
"vite": "bin/vite.js"
},
"engines": {
"node": "^20.19.0 || >=22.12.0"
},
"funding": {
"url": "https://github.com/vitejs/vite?sponsor=1"
},
"optionalDependencies": {
"fsevents": "~2.3.3"
},
"peerDependencies": {
"@types/node": "^20.19.0 || >=22.12.0",
"@vitejs/devtools": "^0.4.0",
"esbuild": "^0.27.0 || ^0.28.0",
"jiti": ">=1.21.0",
"less": "^4.0.0",
"sass": "^1.70.0",
"sass-embedded": "^1.70.0",
"stylus": ">=0.54.8",
"sugarss": "^5.0.0",
"terser": "^5.16.0",
"tsx": "^4.8.1",
"yaml": "^2.4.2"
},
"peerDependenciesMeta": {
"@types/node": {
"optional": true
},
"@vitejs/devtools": {
"optional": true
},
"esbuild": {
"optional": true
},
"jiti": {
"optional": true
},
"less": {
"optional": true
},
"sass": {
"optional": true
},
"sass-embedded": {
"optional": true
},
"stylus": {
"optional": true
},
"sugarss": {
"optional": true
},
"terser": {
"optional": true
},
"tsx": {
"optional": true
},
"yaml": {
"optional": true
}
}
}
}
}

19
frontend/package.json Normal file
View file

@ -0,0 +1,19 @@
{
"name": "agmail-frontend",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"@vitejs/plugin-react": "latest",
"vite": "latest",
"react": "latest",
"react-dom": "latest",
"lucide-react": "latest"
},
"devDependencies": {}
}

27
frontend/src/App.jsx Normal file
View file

@ -0,0 +1,27 @@
import DashboardPage from "./components/DashboardPage";
import LoginPage from "./components/LoginPage";
import MobileLanding from "./components/MobileLanding";
import SignupPage from "./components/SignupPage";
import SmtpCredentialsPage from "./components/SmtpCredentialsPage";
import WebLanding from "./components/WebLanding";
export default function App() {
if (window.location.pathname === "/app") {
return <DashboardPage />;
}
if (window.location.pathname === "/login") {
return <LoginPage />;
}
if (window.location.pathname === "/signup") {
return <SignupPage />;
}
if (window.location.pathname === "/smtp-credentials") {
return <SmtpCredentialsPage />;
}
return (
<>
<div className="web-view"><WebLanding /></div>
<div className="mobile-view"><MobileLanding /></div>
</>
);
}

106
frontend/src/api.js Normal file
View file

@ -0,0 +1,106 @@
const API_BASE = "/api/v1";
function basicAuthHeader({ username, password }) {
return `Basic ${btoa(`${username}:${password}`)}`;
}
async function request(path, { method = "GET", payload, token, basicAuth } = {}) {
const response = await fetch(`${API_BASE}${path}`, {
method,
headers: {
...(payload ? { "Content-Type": "application/json" } : {}),
...(token ? { Authorization: `Bearer ${token}` } : {}),
...(basicAuth
? { Authorization: basicAuthHeader(basicAuth) }
: {}),
},
...(payload ? { body: JSON.stringify(payload) } : {}),
});
const data = await response.json().catch(() => ({}));
if (!response.ok) {
const message = Array.isArray(data.detail)
? data.detail[0]?.msg
: data.detail;
throw new Error(message || "Something went wrong. Please try again.");
}
return data;
}
export const login = (payload) =>
request("/auth/login", { method: "POST", payload });
export const register = (payload) =>
request("/auth/register", { method: "POST", payload });
export const getCurrentAccount = (token) =>
request("/auth/me", { token });
export const getMyCompanyDomain = (token) =>
request("/domains/me", { token });
export const verifyCompanyDns = (token) =>
request("/domains/me/verify-dns", { method: "POST", token });
export const sendEmail = (payload, token) =>
request("/emails", { method: "POST", payload, token });
function emailQuery(parameters = {}) {
const query = new URLSearchParams();
Object.entries(parameters).forEach(([key, value]) => {
if (value !== undefined && value !== null && value !== "") {
query.set(key, String(value));
}
});
const encoded = query.toString();
return encoded ? `?${encoded}` : "";
}
export const listEmails = (parameters, token) =>
request(`/emails${emailQuery(parameters)}`, { token });
export const getEmail = (emailId, token) =>
request(`/emails/${encodeURIComponent(emailId)}`, { token });
export const setEmailRead = (emailId, isRead, token) =>
request(`/emails/${encodeURIComponent(emailId)}/read`, {
method: "PATCH",
payload: { is_read: isRead },
token,
});
export const setEmailStar = (emailId, isStarred, token) =>
request(`/emails/${encodeURIComponent(emailId)}/star`, {
method: "PATCH",
payload: { is_starred: isStarred },
token,
});
export const trashEmail = (emailId, token) =>
request(`/emails/${encodeURIComponent(emailId)}`, {
method: "DELETE",
token,
});
export const restoreEmail = (emailId, token) =>
request(`/emails/${encodeURIComponent(emailId)}/restore`, {
method: "POST",
token,
});
export const permanentlyDeleteEmail = (emailId, token) =>
request(`/emails/${encodeURIComponent(emailId)}/permanent`, {
method: "DELETE",
token,
});
export const listStickyNotes = (token) =>
request("/sticky-notes", { token });
export const createStickyNote = (payload, token) =>
request("/sticky-notes", { method: "POST", payload, token });
export const updateStickyNote = (noteId, payload, token) =>
request(`/sticky-notes/${encodeURIComponent(noteId)}`, {
method: "PATCH",
payload,
token,
});
export const deleteStickyNote = (noteId, token) =>
request(`/sticky-notes/${encodeURIComponent(noteId)}`, {
method: "DELETE",
token,
});
export const previewDnsSetup = (payload, basicAuth) =>
request("/dns-setup/preview", {
method: "POST",
payload,
basicAuth,
});

View file

@ -0,0 +1,27 @@
import { ArrowLeft, Bot, ShieldCheck, Sparkles } from "lucide-react";
import Brand from "./Brand";
export default function AuthLayout({ children, eyebrow, title, description }) {
return (
<main className="auth-page">
<nav className="auth-nav">
<Brand />
<a className="back-link" href="/"><ArrowLeft size={15} /> Back home</a>
</nav>
<div className="auth-orb auth-orb-one" />
<div className="auth-orb auth-orb-two" />
<section className="auth-shell">
<div className="auth-story">
<div className="eyebrow"><Sparkles size={13} /> {eyebrow}</div>
<h1>{title}</h1>
<p>{description}</p>
<div className="auth-points">
<span><Bot size={17} /> Ready for your AI agents</span>
<span><ShieldCheck size={17} /> Private by design</span>
</div>
</div>
<div className="auth-card">{children}</div>
</section>
</main>
);
}

View file

@ -0,0 +1,11 @@
export default function Brand({ compact = false }) {
return (
<a className={`brand${compact ? " brand-compact" : ""}`} href="/" aria-label="Agmail home">
<span className="brand-mark" aria-hidden="true">
<span className="robot-eyes"><i /><i /></span>
<span className="robot-smile" />
</span>
{!compact && <span>agmail</span>}
</a>
);
}

View file

@ -0,0 +1,139 @@
import { ArrowRight, CheckCircle2, Copy, XCircle } from "lucide-react";
import { useState } from "react";
import { verifyCompanyDns } from "../api";
import Brand from "./Brand";
function DnsRecordTable({ records }) {
async function copyValue(value) {
try {
await navigator.clipboard.writeText(value);
} catch {
/* clipboard may be unavailable */
}
}
return (
<div className="dns-table-wrap">
<table className="dns-table">
<thead>
<tr>
<th>Type</th>
<th>Host</th>
<th>Value</th>
<th>TTL</th>
</tr>
</thead>
<tbody>
{records.map((record) => {
const rowKey = `${record.record_type}-${record.host}-${record.purpose}`;
const displayValue = record.record_type === "MX"
? `10 ${record.value}`
: record.value;
return (
<tr key={rowKey}>
<td>{record.record_type}</td>
<td><code>{record.host}</code></td>
<td>
<div className="dns-value-cell">
<code>{displayValue}</code>
<button
type="button"
className="dns-copy"
aria-label="Copy value"
onClick={() => copyValue(displayValue)}
>
<Copy size={14} />
</button>
</div>
</td>
<td>{record.ttl}</td>
</tr>
);
})}
</tbody>
</table>
</div>
);
}
export default function CompanyDnsSetup({ domain, account, onComplete, onLogout }) {
const token = sessionStorage.getItem("agmail_access_token");
const [status, setStatus] = useState({ loading: false, error: "" });
const [checks, setChecks] = useState(null);
async function handleVerify() {
setStatus({ loading: true, error: "" });
setChecks(null);
try {
const result = await verifyCompanyDns(token);
setChecks(result.checks);
if (result.verified) {
setStatus({ loading: false, error: "" });
onComplete(result);
return;
}
setStatus({
loading: false,
error: "Some DNS records are missing or still propagating. Update your registrar and try again.",
});
} catch (error) {
setStatus({ loading: false, error: error.message });
}
}
return (
<main className="company-dns-setup">
<header className="company-dns-header">
<Brand />
<button type="button" onClick={onLogout}>Log out</button>
</header>
<section className="company-dns-card">
<p className="eyebrow">Company setup</p>
<h1>Connect <em>{domain.domain_name}</em></h1>
<p className="company-dns-lead">
Add these DNS records at your domain registrar for <strong>{account.email}</strong>.
Mail uses shared hosting at <strong>{domain.mail_host}</strong>, like Zoho or Namecheap Private Email.
</p>
<DnsRecordTable records={domain.dns_records} />
<ul className="dns-notes">
<li>Point MX to <strong>{domain.mail_host}</strong> — you do not need a separate mail A record.</li>
<li>SPF uses <code>include:_spf.getall.help</code> — that record must exist on <strong>getall.help</strong> (Host <code>_spf</code>, TXT <code>v=spf1 ip4:&lt;mail-server-ip&gt; ~all</code>).</li>
<li>DNS changes can take a few minutes to propagate.</li>
</ul>
{checks && checks.length > 0 && (
<div className="company-dns-checks">
{checks.map((check) => (
<div
key={check.purpose}
className={`company-dns-check${check.verified ? " ok" : " fail"}`}
>
{check.verified ? <CheckCircle2 size={16} /> : <XCircle size={16} />}
<div>
<strong>{check.purpose.toUpperCase()}</strong>
<span>{check.message}</span>
</div>
</div>
))}
</div>
)}
{status.error && (
<p className="form-error" role="alert">{status.error}</p>
)}
<button
className="button button-coral company-dns-next"
type="button"
disabled={status.loading}
onClick={handleVerify}
>
{status.loading ? "Checking DNS…" : "Next — verify DNS"} <ArrowRight size={16} />
</button>
</section>
</main>
);
}

View file

@ -0,0 +1,166 @@
import {
Bold,
Italic,
Link,
List,
Send,
Sparkles,
Underline,
X,
} from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { sendEmail } from "../api";
function parseAddresses(value) {
return value
.split(/[,\n;]/)
.map((address) => address.trim())
.filter(Boolean);
}
function plainTextFromHtml(value) {
const document = new DOMParser().parseFromString(value, "text/html");
return document.body.textContent || "";
}
export default function ComposeModal({ account, onClose, onSent }) {
const [format, setFormat] = useState("plain");
const [showCopies, setShowCopies] = useState(false);
const [body, setBody] = useState("");
const [aiPrompt, setAiPrompt] = useState("");
const [status, setStatus] = useState({ sending: false, error: "" });
const bodyRef = useRef(null);
useEffect(() => {
function closeOnEscape(event) {
if (event.key === "Escape" && !status.sending) onClose();
}
document.addEventListener("keydown", closeOnEscape);
return () => document.removeEventListener("keydown", closeOnEscape);
}, [onClose, status.sending]);
function helpWrite() {
const request = aiPrompt.trim();
if (!request) return;
const draft = format === "html"
? `<p>Hi,</p><p>${request}</p><p>Best,<br>${account.first_name}</p>`
: `Hi,\n\n${request}\n\nBest,\n${account.first_name}`;
setBody(draft);
setAiPrompt("");
}
function applyHtml(openingTag, closingTag) {
const editor = bodyRef.current;
if (!editor || format !== "html") return;
const start = editor.selectionStart;
const end = editor.selectionEnd;
const selected = body.slice(start, end) || "text";
setBody(`${body.slice(0, start)}${openingTag}${selected}${closingTag}${body.slice(end)}`);
window.requestAnimationFrame(() => editor.focus());
}
async function submit(event) {
event.preventDefault();
const form = new FormData(event.currentTarget);
setStatus({ sending: true, error: "" });
try {
await sendEmail(
{
recipients: parseAddresses(form.get("to")),
cc: parseAddresses(form.get("cc") || ""),
bcc: parseAddresses(form.get("bcc") || ""),
subject: form.get("subject"),
body_text: format === "plain" ? body : plainTextFromHtml(body),
body_html: format === "html" ? body : null,
},
sessionStorage.getItem("agmail_access_token"),
);
onSent();
} catch (error) {
setStatus({ sending: false, error: error.message });
}
}
return (
<div className="compose-overlay" role="presentation">
<section className="compose-modal" role="dialog" aria-modal="true" aria-labelledby="compose-title">
<header className="compose-header">
<div>
<span><Sparkles size={14} /></span>
<div><h2 id="compose-title">New message</h2><small>From {account.email}</small></div>
</div>
<button type="button" aria-label="Close compose" onClick={onClose} disabled={status.sending}>
<X size={20} />
</button>
</header>
<form className="compose-form" onSubmit={submit}>
<div className="compose-recipient-row">
<label htmlFor="compose-to">To</label>
<input id="compose-to" name="to" type="text" placeholder="name@example.com" required autoFocus />
<button type="button" onClick={() => setShowCopies((visible) => !visible)}>Cc · Bcc</button>
</div>
{showCopies && (
<div className="compose-copy-fields">
<label>Cc<input name="cc" type="text" placeholder="Copy recipients" /></label>
<label>Bcc<input name="bcc" type="text" placeholder="Hidden recipients" /></label>
</div>
)}
<input className="compose-subject" name="subject" placeholder="Subject" maxLength="998" />
<div className="compose-format-row">
<span>Message format</span>
<div role="tablist" aria-label="Message format">
<button className={format === "plain" ? "active" : ""} type="button" onClick={() => setFormat("plain")}>Plain</button>
<button className={format === "html" ? "active" : ""} type="button" onClick={() => setFormat("html")}>HTML</button>
</div>
</div>
<textarea
className="compose-body"
ref={bodyRef}
value={body}
onChange={(event) => setBody(event.target.value)}
placeholder={format === "html" ? "<p>Write your HTML email…</p>" : "Write your message…"}
required
/>
<div className="compose-tools" aria-label="Message tools">
<button type="button" title="Bold" disabled={format !== "html"} onClick={() => applyHtml("<strong>", "</strong>")}><Bold size={16} /></button>
<button type="button" title="Italic" disabled={format !== "html"} onClick={() => applyHtml("<em>", "</em>")}><Italic size={16} /></button>
<button type="button" title="Underline" disabled={format !== "html"} onClick={() => applyHtml("<u>", "</u>")}><Underline size={16} /></button>
<button type="button" title="List" disabled={format !== "html"} onClick={() => applyHtml("<ul><li>", "</li></ul>")}><List size={17} /></button>
<button type="button" title="Add link" disabled={format !== "html"} onClick={() => applyHtml('<a href="https://">', "</a>")}><Link size={17} /></button>
{format === "plain" && <span>Formatting tools are available in HTML mode</span>}
</div>
<div className="compose-ai">
<Sparkles size={18} />
<input
value={aiPrompt}
onChange={(event) => setAiPrompt(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter") {
event.preventDefault();
helpWrite();
}
}}
placeholder="Describe what you want to say"
/>
<button type="button" onClick={helpWrite} disabled={!aiPrompt.trim()}>
Help me write
</button>
</div>
{status.error && <p className="compose-error" role="alert">{status.error}</p>}
<footer className="compose-footer">
<span>Sent securely through agmail SMTP</span>
<button className="compose-send" type="submit" disabled={status.sending}>
{status.sending ? "Sending…" : "Send"} <Send size={16} />
</button>
</footer>
</form>
</section>
</div>
);
}

View file

@ -0,0 +1,213 @@
import {
Bot,
Clock3,
Inbox,
ListFilter,
LogOut,
Mail,
Menu,
Plug,
Send,
Settings,
ShieldAlert,
Sparkles,
Tags,
Trash2,
X,
} from "lucide-react";
import { useEffect, useState } from "react";
import { getCurrentAccount, getMyCompanyDomain } from "../api";
import Brand from "./Brand";
import CompanyDnsSetup from "./CompanyDnsSetup";
import ComposeModal from "./ComposeModal";
import MailboxView from "./MailboxView";
const mailItems = [
[Inbox, "Inbox"],
[Send, "Outbox"],
[ShieldAlert, "Spam"],
[Trash2, "Trash"],
[Clock3, "Scheduled"],
[Settings, "Settings"],
];
const agentItems = [
[Tags, "Tags"],
[ListFilter, "Filters"],
[Sparkles, "Summary"],
[Plug, "Integration"],
];
function clearSession() {
sessionStorage.removeItem("agmail_access_token");
sessionStorage.removeItem("agmail_account");
}
function NavGroup({ icon: Icon, title, items, active, onSelect }) {
return (
<section className="dashboard-nav-group">
<h2><Icon size={15} />{title}</h2>
<nav aria-label={title}>
{items.map(([ItemIcon, label]) => (
<button
className={active === label ? "active" : ""}
key={label}
onClick={() => onSelect(label)}
type="button"
>
<ItemIcon size={17} />
<span>{label}</span>
</button>
))}
</nav>
</section>
);
}
export default function DashboardPage() {
const [account, setAccount] = useState(null);
const [companyDomain, setCompanyDomain] = useState(null);
const [needsDnsSetup, setNeedsDnsSetup] = useState(false);
const [domainError, setDomainError] = useState("");
const [active, setActive] = useState("Inbox");
const [menuOpen, setMenuOpen] = useState(false);
const [composeOpen, setComposeOpen] = useState(false);
const [notice, setNotice] = useState("");
const [loading, setLoading] = useState(true);
const [mailRefreshKey, setMailRefreshKey] = useState(0);
useEffect(() => {
const token = sessionStorage.getItem("agmail_access_token");
if (!token) {
window.location.replace("/login");
return;
}
getCurrentAccount(token)
.then(async (currentAccount) => {
sessionStorage.setItem("agmail_account", JSON.stringify(currentAccount));
setAccount(currentAccount);
if (currentAccount.account_type === "company") {
try {
const domain = await getMyCompanyDomain(token);
setCompanyDomain(domain);
setNeedsDnsSetup(domain.status === "pending_dns");
} catch (error) {
setDomainError(error.message || "Could not load your company domain.");
}
}
setLoading(false);
})
.catch(() => {
clearSession();
window.location.replace("/login");
});
}, []);
function logout() {
clearSession();
window.location.assign("/login");
}
function selectItem(item) {
setActive(item);
setMenuOpen(false);
}
function emailSent() {
setComposeOpen(false);
setActive("Outbox");
setMailRefreshKey((key) => key + 1);
setNotice("Message accepted by the mail server. Delivery can take a few minutes — check spam if it does not arrive.");
window.setTimeout(() => setNotice(""), 4000);
}
if (loading || !account) {
return <main className="dashboard-loading"><Brand /><span>Opening your workspace…</span></main>;
}
if (account.account_type === "company" && domainError) {
return (
<main className="company-dns-setup">
<header className="company-dns-header">
<Brand />
<button type="button" onClick={logout}>Log out</button>
</header>
<section className="company-dns-card">
<p className="eyebrow">Company setup</p>
<h1>Domain not ready</h1>
<p className="form-error" role="alert">{domainError}</p>
</section>
</main>
);
}
if (needsDnsSetup && companyDomain) {
return (
<CompanyDnsSetup
account={account}
domain={companyDomain}
onComplete={() => {
setCompanyDomain({ ...companyDomain, status: "active" });
setNeedsDnsSetup(false);
}}
onLogout={logout}
/>
);
}
const workspaceLabel = account.account_type === "company" ? "Company" : "Individual";
const workspaceTitle = account.account_type === "company" && companyDomain
? companyDomain.domain_name
: "Personal workspace";
return (
<div className="dashboard">
<header className="dashboard-mobile-header">
<Brand />
<button aria-label="Open navigation" type="button" onClick={() => setMenuOpen(true)}>
<Menu size={21} />
</button>
</header>
{menuOpen && <button className="dashboard-scrim" aria-label="Close navigation" onClick={() => setMenuOpen(false)} />}
<aside className={menuOpen ? "dashboard-sidebar open" : "dashboard-sidebar"}>
<div className="dashboard-brand-row">
<Brand />
<button aria-label="Close navigation" type="button" onClick={() => setMenuOpen(false)}><X size={19} /></button>
</div>
<button className="dashboard-compose" type="button" onClick={() => setComposeOpen(true)}>
<Mail size={17} />Compose
</button>
<NavGroup icon={Mail} title="Mail" items={mailItems} active={active} onSelect={selectItem} />
<NavGroup icon={Bot} title="Agent" items={agentItems} active={active} onSelect={selectItem} />
<div className="dashboard-profile">
<span>{account.first_name[0]}{account.last_name[0]}</span>
<div><strong>{account.first_name} {account.last_name}</strong><small>{account.email}</small></div>
<button aria-label="Log out" title="Log out" type="button" onClick={logout}><LogOut size={17} /></button>
</div>
</aside>
<main className="dashboard-content">
{!["Inbox", "Outbox", "Trash"].includes(active) && (
<div className="dashboard-content-heading">
<div><p>{workspaceTitle}</p><h1>{active}</h1></div>
<span className="dashboard-account-pill">{workspaceLabel}</span>
</div>
)}
{active === "Inbox" && <MailboxView mailbox="inbox" title="Inbox" refreshKey={mailRefreshKey} />}
{active === "Outbox" && <MailboxView mailbox="sent" title="Sent" refreshKey={mailRefreshKey} />}
{active === "Trash" && <MailboxView mailbox="trash" title="Trash" refreshKey={mailRefreshKey} />}
{!["Inbox", "Outbox", "Trash"].includes(active) && (
<section className="dashboard-empty">
<span><Sparkles size={24} /></span>
<h2>{active} is ready for you</h2>
<p>Your personal mail and agent tools will appear here.</p>
</section>
)}
</main>
{notice && <div className="dashboard-notice" role="status">{notice}</div>}
{composeOpen && <ComposeModal account={account} onClose={() => setComposeOpen(false)} onSent={emailSent} />}
</div>
);
}

View file

@ -0,0 +1,46 @@
import { ArrowRight, LockKeyhole, Mail } from "lucide-react";
import { useState } from "react";
import { login } from "../api";
import AuthLayout from "./AuthLayout";
export default function LoginPage() {
const [status, setStatus] = useState({ loading: false, error: "" });
async function handleSubmit(event) {
event.preventDefault();
setStatus({ loading: true, error: "" });
const form = new FormData(event.currentTarget);
try {
const result = await login({
email: form.get("email"),
password: form.get("password"),
});
sessionStorage.setItem("agmail_access_token", result.access_token);
sessionStorage.setItem("agmail_account", JSON.stringify(result.account));
window.location.assign("/app");
} catch (error) {
setStatus({ loading: false, error: error.message });
}
}
return (
<AuthLayout
eyebrow="Welcome back"
title={<>Your inbox<br /><em>missed you.</em></>}
description="Sign in to continue working alongside your agents."
>
<>
<div className="card-heading"><h2>Log in to agmail</h2><p>Enter your account details below.</p></div>
<form className="auth-form" onSubmit={handleSubmit}>
<label>Email address<div className="input-wrap"><Mail size={16} /><input name="email" type="email" placeholder="you@getall.help" required autoComplete="email" /></div></label>
<label>Password<div className="input-wrap"><LockKeyhole size={16} /><input name="password" type="password" placeholder="Your password" required autoComplete="current-password" /></div></label>
{status.error && <p className="form-error" role="alert">{status.error}</p>}
<button className="button button-coral auth-submit" disabled={status.loading}>
{status.loading ? "Signing in…" : "Log in"} <ArrowRight size={16} />
</button>
</form>
<p className="auth-switch">New to agmail? <a href="/signup">Create an account</a></p>
</>
</AuthLayout>
);
}

View file

@ -0,0 +1,110 @@
import {
ArrowLeft,
Mail,
MailOpen,
RotateCcw,
Trash2,
X,
} from "lucide-react";
import SenderAvatar from "./SenderAvatar";
import { senderDisplayName } from "../utils/senderAvatar";
function joinAddresses(value) {
if (!value) return "";
const addresses = Array.isArray(value) ? value : [value];
return addresses
.map((item) => (typeof item === "string" ? item : item?.email || item?.address))
.filter(Boolean)
.join(", ");
}
function detailDate(email) {
const value =
email?.received_at || email?.sent_at || email?.created_at || email?.updated_at;
if (!value) return "";
const date = new Date(value);
if (Number.isNaN(date.getTime())) return "";
return new Intl.DateTimeFormat(undefined, {
dateStyle: "medium",
timeStyle: "short",
}).format(date);
}
export default function MailDetail({
email,
loading,
error,
mailbox,
busy,
onClose,
onToggleRead,
onTrash,
onRestore,
onDelete,
}) {
return (
<article className={`mail-detail${email || loading ? " open" : ""}`} aria-live="polite">
<header className="mail-detail-toolbar">
<button type="button" onClick={onClose} aria-label="Back to message list">
<ArrowLeft size={18} />
</button>
<div>
{email && (
<button
type="button"
disabled={busy}
onClick={() => onToggleRead(!email.is_read)}
title={email.is_read ? "Mark unread" : "Mark read"}
aria-label={email.is_read ? "Mark unread" : "Mark read"}
>
{email.is_read ? <Mail size={17} /> : <MailOpen size={17} />}
</button>
)}
{email && mailbox === "trash" && (
<button type="button" disabled={busy} onClick={onRestore} title="Restore" aria-label="Restore message">
<RotateCcw size={17} />
</button>
)}
{email && (
<button
className={mailbox === "trash" ? "danger" : ""}
type="button"
disabled={busy}
onClick={mailbox === "trash" ? onDelete : onTrash}
title={mailbox === "trash" ? "Delete permanently" : "Move to trash"}
aria-label={mailbox === "trash" ? "Delete message permanently" : "Move message to trash"}
>
<Trash2 size={17} />
</button>
)}
<button className="mail-detail-close" type="button" onClick={onClose} aria-label="Close message">
<X size={18} />
</button>
</div>
</header>
{loading && <div className="mail-detail-state">Opening message…</div>}
{error && <div className="mail-detail-state error" role="alert">{error}</div>}
{email && !loading && (
<div className="mail-detail-content">
<h2>{email.subject?.trim() || "(No subject)"}</h2>
<div className="mail-detail-meta">
<div>
<div className="mail-detail-sender">
<SenderAvatar email={email} />
<strong>{senderDisplayName(email)}</strong>
</div>
<small>From: {joinAddresses(email.sender) || "Unknown"}</small>
<small>To: {joinAddresses(email.recipients || email.to) || "Undisclosed"}</small>
{joinAddresses(email.cc) && <small>Cc: {joinAddresses(email.cc)}</small>}
</div>
<time>{detailDate(email)}</time>
</div>
<div className="mail-detail-body">
{email.body_text || email.text_body || email.plain_text || "This message has no plain-text content."}
</div>
</div>
)}
</article>
);
}

View file

@ -0,0 +1,75 @@
import { MailOpen, Star } from "lucide-react";
import SenderAvatar from "./SenderAvatar";
import { senderDisplayName } from "../utils/senderAvatar";
function formatTimestamp(value) {
if (!value) return "";
const date = new Date(value);
if (Number.isNaN(date.getTime())) return "";
const now = new Date();
const sameDay = date.toDateString() === now.toDateString();
if (sameDay) {
return new Intl.DateTimeFormat(undefined, {
hour: "numeric",
minute: "2-digit",
}).format(date);
}
return new Intl.DateTimeFormat(undefined, {
month: "short",
day: "numeric",
...(date.getFullYear() !== now.getFullYear() ? { year: "numeric" } : {}),
}).format(date);
}
export default function MailRow({ email, selected, onOpen, onToggleStar }) {
const unread = email.is_read === false;
const starred = email.is_starred === true;
const subject = email.subject?.trim() || "(No subject)";
const snippet = email.snippet || email.preview || email.body_text || "";
const timestamp =
email.received_at || email.sent_at || email.created_at || email.updated_at;
const senderName = senderDisplayName(email);
function handleKeyDown(event) {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
onOpen(email);
}
}
function handleStarClick(event) {
event.stopPropagation();
onToggleStar?.(email, !starred);
}
return (
<div
className={`mailbox-row${unread ? " unread" : ""}${selected ? " selected" : ""}${starred ? " starred" : ""}`}
role="button"
tabIndex={0}
onClick={() => onOpen(email)}
onKeyDown={handleKeyDown}
aria-label={`${unread ? "Unread" : "Read"} email from ${senderName}: ${subject}`}
>
<span className="mailbox-read-dot" aria-hidden="true">
{unread ? <i /> : <MailOpen size={14} />}
</span>
<SenderAvatar email={email} className="mailbox-avatar" />
<span className="mailbox-sender">{senderName}</span>
<span className="mailbox-summary">
<strong>{subject}</strong>
{snippet && <span><b aria-hidden="true">—</b> {snippet}</span>}
</span>
<time dateTime={timestamp || undefined}>{formatTimestamp(timestamp)}</time>
<button
type="button"
className={`mailbox-star${starred ? " active" : ""}`}
onClick={handleStarClick}
aria-label={starred ? "Unstar message" : "Star message"}
aria-pressed={starred}
>
<Star size={15} fill={starred ? "currentColor" : "none"} />
</button>
</div>
);
}

View file

@ -0,0 +1,304 @@
import {
ChevronLeft,
ChevronRight,
Filter,
Inbox,
RefreshCw,
Search,
} from "lucide-react";
import { useCallback, useEffect, useState } from "react";
import {
getEmail,
listEmails,
permanentlyDeleteEmail,
restoreEmail,
setEmailRead,
setEmailStar,
trashEmail,
} from "../api";
import MailDetail from "./MailDetail";
import MailRow from "./MailRow";
import StickyNotesPanel from "./StickyNotesPanel";
const PAGE_SIZE = 25;
function resultPage(result) {
if (Array.isArray(result)) return { items: result, nextCursor: null };
return {
items: result.items || result.emails || result.results || [],
nextCursor: result.next_cursor || result.nextCursor || null,
};
}
export default function MailboxView({ mailbox, title, refreshKey = 0 }) {
const token = sessionStorage.getItem("agmail_access_token");
const [emails, setEmails] = useState([]);
const [nextCursor, setNextCursor] = useState(null);
const [cursor, setCursor] = useState(null);
const [cursorHistory, setCursorHistory] = useState([]);
const [selected, setSelected] = useState(null);
const [detail, setDetail] = useState(null);
const [status, setStatus] = useState({ loading: true, error: "" });
const [detailStatus, setDetailStatus] = useState({ loading: false, error: "", busy: false });
const [filtersOpen, setFiltersOpen] = useState(false);
const [filters, setFilters] = useState({
query: "",
read: "all",
sender: "",
dateFrom: "",
dateTo: "",
});
const load = useCallback(async () => {
setStatus({ loading: true, error: "" });
try {
const result = await listEmails(
{
mailbox,
limit: PAGE_SIZE,
cursor,
q: filters.query.trim(),
is_read: filters.read === "all" ? undefined : filters.read === "read",
sender: filters.sender.trim(),
date_from: filters.dateFrom || undefined,
date_to: filters.dateTo || undefined,
},
token,
);
const page = resultPage(result);
setEmails(page.items);
setNextCursor(page.nextCursor);
setStatus({ loading: false, error: "" });
} catch (error) {
setStatus({ loading: false, error: error.message });
}
}, [cursor, filters, mailbox, token]);
useEffect(() => {
const timeout = window.setTimeout(load, filters.query ? 250 : 0);
return () => window.clearTimeout(timeout);
}, [load, refreshKey]);
function changeFilter(name, value) {
setFilters((current) => ({ ...current, [name]: value }));
setCursor(null);
setCursorHistory([]);
}
async function openEmail(email) {
setSelected(email.id);
setDetail(email);
setDetailStatus({ loading: true, error: "", busy: false });
if (email.is_read === false) {
setEmails((current) =>
current.map((item) => item.id === email.id ? { ...item, is_read: true } : item),
);
setDetail((current) => ({ ...current, is_read: true }));
setEmailRead(email.id, true, token).catch(() => {
setEmails((current) =>
current.map((item) => item.id === email.id ? { ...item, is_read: false } : item),
);
setDetail((current) =>
current?.id === email.id ? { ...current, is_read: false } : current,
);
});
}
try {
const completeEmail = await getEmail(email.id, token);
setDetail((current) => ({ ...current, ...completeEmail, is_read: current?.is_read ?? completeEmail.is_read }));
setDetailStatus({ loading: false, error: "", busy: false });
} catch (error) {
setDetailStatus({ loading: false, error: error.message, busy: false });
}
}
async function toggleStar(email, isStarred) {
setEmails((current) =>
current.map((item) => item.id === email.id ? { ...item, is_starred: isStarred } : item),
);
if (detail?.id === email.id) {
setDetail((current) => ({ ...current, is_starred: isStarred }));
}
try {
await setEmailStar(email.id, isStarred, token);
} catch (error) {
setEmails((current) =>
current.map((item) => item.id === email.id ? { ...item, is_starred: !isStarred } : item),
);
if (detail?.id === email.id) {
setDetail((current) => ({ ...current, is_starred: !isStarred }));
}
}
}
async function toggleRead(isRead) {
if (!detail) return;
setDetailStatus((current) => ({ ...current, busy: true, error: "" }));
try {
await setEmailRead(detail.id, isRead, token);
setDetail((current) => ({ ...current, is_read: isRead }));
setEmails((current) =>
current.map((item) => item.id === detail.id ? { ...item, is_read: isRead } : item),
);
setDetailStatus((current) => ({ ...current, busy: false }));
} catch (error) {
setDetailStatus((current) => ({ ...current, busy: false, error: error.message }));
}
}
async function mutateSelected(action) {
if (!detail) return;
setDetailStatus((current) => ({ ...current, busy: true, error: "" }));
try {
await action(detail.id, token);
setEmails((current) => current.filter((item) => item.id !== detail.id));
setSelected(null);
setDetail(null);
setDetailStatus({ loading: false, error: "", busy: false });
} catch (error) {
setDetailStatus((current) => ({ ...current, busy: false, error: error.message }));
}
}
function deletePermanently() {
if (window.confirm("Permanently delete this message? This cannot be undone.")) {
mutateSelected(permanentlyDeleteEmail);
}
}
function nextPage() {
if (!nextCursor) return;
setCursorHistory((history) => [...history, cursor]);
setCursor(nextCursor);
setSelected(null);
setDetail(null);
}
function previousPage() {
if (!cursorHistory.length) return;
const previous = cursorHistory[cursorHistory.length - 1];
setCursorHistory((history) => history.slice(0, -1));
setCursor(previous);
setSelected(null);
setDetail(null);
}
const hasFilters =
filters.read !== "all" || filters.sender || filters.dateFrom || filters.dateTo;
return (
<section className="mailbox-workspace">
<div className="mailbox-main-column">
<div className="dashboard-content-heading">
<div><p>Personal workspace</p><h1>{title}</h1></div>
<span className="dashboard-account-pill">Individual</span>
</div>
<div className={`mailbox-shell${selected ? " detail-visible" : ""}`}>
<div className="mailbox-list-pane">
<div className="mailbox-toolbar">
<label>
<Search size={16} />
<input
type="search"
value={filters.query}
onChange={(event) => changeFilter("query", event.target.value)}
placeholder={`Search ${title.toLowerCase()}`}
/>
</label>
<button
className={filtersOpen || hasFilters ? "active" : ""}
type="button"
onClick={() => setFiltersOpen((open) => !open)}
aria-expanded={filtersOpen}
>
<Filter size={16} /> <span>Filters</span>
</button>
<button type="button" onClick={load} aria-label="Refresh messages" title="Refresh">
<RefreshCw size={16} />
</button>
</div>
{filtersOpen && (
<div className="mailbox-filters">
<label>Read status
<select value={filters.read} onChange={(event) => changeFilter("read", event.target.value)}>
<option value="all">All messages</option>
<option value="unread">Unread</option>
<option value="read">Read</option>
</select>
</label>
<label>Sender
<input value={filters.sender} onChange={(event) => changeFilter("sender", event.target.value)} placeholder="name@example.com" />
</label>
<label>From
<input type="date" value={filters.dateFrom} onChange={(event) => changeFilter("dateFrom", event.target.value)} />
</label>
<label>To
<input type="date" value={filters.dateTo} onChange={(event) => changeFilter("dateTo", event.target.value)} />
</label>
</div>
)}
<div className="mailbox-rows" aria-busy={status.loading}>
{status.loading && <div className="mailbox-state">Loading messages…</div>}
{status.error && (
<div className="mailbox-state error" role="alert">
<p>{status.error}</p><button type="button" onClick={load}>Try again</button>
</div>
)}
{!status.loading && !status.error && emails.length === 0 && (
<div className="mailbox-state empty">
<span><Inbox size={22} /></span>
<strong>No messages here</strong>
<p>{hasFilters || filters.query ? "Try changing your search or filters." : `Your ${title.toLowerCase()} is empty.`}</p>
</div>
)}
{!status.loading && !status.error && emails.map((email) => (
<MailRow
key={email.id}
email={email}
selected={selected === email.id}
onOpen={openEmail}
onToggleStar={toggleStar}
/>
))}
</div>
{!status.loading && !status.error && emails.length > 0 && (
<footer className="mailbox-list-footer">
<p>
{emails.length} message{emails.length === 1 ? "" : "s"} in this view
{filters.query.trim() ? " · filtered" : ""}
</p>
<span>You're all caught up</span>
</footer>
)}
{(cursorHistory.length > 0 || nextCursor) && (
<footer className="mailbox-pagination">
<button type="button" disabled={!cursorHistory.length} onClick={previousPage}><ChevronLeft size={16} /> Previous</button>
<span>Page {cursorHistory.length + 1}</span>
<button type="button" disabled={!nextCursor} onClick={nextPage}>Next <ChevronRight size={16} /></button>
</footer>
)}
<MailDetail
email={detail}
loading={detailStatus.loading}
error={detailStatus.error}
busy={detailStatus.busy}
mailbox={mailbox}
onClose={() => { setSelected(null); setDetail(null); }}
onToggleRead={toggleRead}
onTrash={() => mutateSelected(trashEmail)}
onRestore={() => mutateSelected(restoreEmail)}
onDelete={deletePermanently}
/>
</div>
</div>
</div>
<StickyNotesPanel />
</section>
);
}

View file

@ -0,0 +1,34 @@
import { ArrowRight, Bot, Check, Inbox, Menu, Send, Sparkles } from "lucide-react";
import Brand from "./Brand";
export default function MobileLanding() {
return (
<main className="mobile-landing">
<nav className="mobile-nav">
<Brand />
<button className="icon-button" aria-label="Open menu"><Menu size={21} /></button>
</nav>
<section className="mobile-hero">
<div className="eyebrow"><Sparkles size={13} /> Email for humans + agents</div>
<h1>Your inbox,<br /><em>one step ahead.</em></h1>
<p>A focused home for every message, task, and AI agent you work with.</p>
<a className="button button-coral" href="/signup">Create your agmail <ArrowRight size={17} /></a>
<a className="mobile-login" href="/login">Already have an account? <strong>Log in</strong></a>
</section>
<section className="phone-preview" aria-label="Agmail mobile inbox preview">
<div className="phone-top"><span>9:41</span><span className="phone-pill" /><span>•••</span></div>
<header><div><small>Good morning</small><strong>Alex</strong></div><span className="avatar">AK</span></header>
<div className="mobile-summary">
<span className="agent-icon"><Bot size={18} /></span>
<div><strong>Your agent caught you up</strong><small>8 emails handled this morning</small></div>
<ArrowRight size={15} />
</div>
<div className="mobile-inbox-title"><h3>Priority inbox</h3><span>4 new</span></div>
<article className="mobile-mail"><span className="sender-avatar coral">R</span><div><strong>Research agent</strong><p>Market brief is ready</p></div><time>10:42</time></article>
<article className="mobile-mail"><span className="sender-avatar blue">M</span><div><strong>Maya Chen</strong><p>Re: Product direction</p></div><time>9:18</time></article>
<div className="mobile-tabbar"><span className="active"><Inbox size={19} /></span><button><span>+</span></button><span><Send size={19} /></span></div>
</section>
<section className="mobile-benefits"><span><Check size={15} /> Free to start</span><span><Check size={15} /> Private by design</span></section>
</main>
);
}

View file

@ -0,0 +1,23 @@
import { senderAvatar } from "../utils/senderAvatar";
export default function SenderAvatar({ email, className = "" }) {
const { name, initials, color, imageUrl } = senderAvatar(email);
if (imageUrl) {
return (
<span className={`sender-avatar${className ? ` ${className}` : ""}`}>
<img src={imageUrl} alt="" aria-hidden="true" />
</span>
);
}
return (
<span
className={`sender-avatar${className ? ` ${className}` : ""}`}
style={{ backgroundColor: color }}
aria-hidden="true"
>
{initials}
</span>
);
}

View file

@ -0,0 +1,80 @@
import {
ArrowRight, Building2, CalendarDays, Eye, LockKeyhole, Mail, Phone, UserRound,
} from "lucide-react";
import { useState } from "react";
import { register } from "../api";
import AuthLayout from "./AuthLayout";
export default function SignupPage() {
const [accountType, setAccountType] = useState("individual");
const [status, setStatus] = useState({ loading: false, error: "" });
async function handleSubmit(event) {
event.preventDefault();
setStatus({ loading: true, error: "" });
const form = new FormData(event.currentTarget);
try {
const result = await register({
account_type: form.get("account_type"),
email_name: form.get("email_name"),
company_domain: form.get("company_domain") || null,
first_name: form.get("first_name"),
last_name: form.get("last_name"),
date_of_birth: form.get("date_of_birth"),
phone_number: form.get("phone_number"),
password: form.get("password"),
});
sessionStorage.setItem("agmail_access_token", result.access_token);
sessionStorage.setItem("agmail_account", JSON.stringify(result.account));
window.location.assign("/app");
} catch (error) {
setStatus({ loading: false, error: error.message });
}
}
return (
<AuthLayout
eyebrow="Your new digital address"
title={<>An inbox built<br /><em>around you.</em></>}
description="Create a personal address or bring your company's domain."
>
<>
<div className="card-heading"><span>01 — Account setup</span><h2>Create your account</h2><p>Every field is securely processed by agmail.</p></div>
<form className="auth-form signup-form" onSubmit={handleSubmit}>
<fieldset>
<legend>Account type</legend>
<div className="account-types">
<label className={accountType === "individual" ? "type-card selected" : "type-card"}>
<input type="radio" name="account_type" value="individual" checked={accountType === "individual"} onChange={(event) => setAccountType(event.target.value)} />
<UserRound size={19} /><span><strong>Individual</strong><small>Your personal agent inbox</small></span>
</label>
<label className={accountType === "company" ? "type-card selected" : "type-card"}>
<input type="radio" name="account_type" value="company" checked={accountType === "company"} onChange={(event) => setAccountType(event.target.value)} />
<Building2 size={19} /><span><strong>Company</strong><small>Use your business domain</small></span>
</label>
</div>
</fieldset>
{accountType === "company" && (
<label>Company domain<div className="input-wrap"><Building2 size={16} /><input name="company_domain" placeholder="yourcompany.com" required /></div></label>
)}
<label>Choose your email name
<div className="email-builder input-wrap"><Mail size={16} /><input name="email_name" placeholder="alex" pattern="[A-Za-z0-9](?:[A-Za-z0-9._-]{0,62}[A-Za-z0-9])?" required /><span>{accountType === "individual" ? "@getall.help" : "@your domain"}</span></div>
</label>
<div className="field-row">
<label>First name<div className="input-wrap"><UserRound size={16} /><input name="first_name" placeholder="Alex" required /></div></label>
<label>Last name<div className="input-wrap"><UserRound size={16} /><input name="last_name" placeholder="Morgan" required /></div></label>
</div>
<label>Date of birth<div className="input-wrap"><CalendarDays size={16} /><input name="date_of_birth" type="date" required /></div></label>
<label>Phone number<div className="input-wrap"><Phone size={16} /><input name="phone_number" type="tel" placeholder="+1 555 123 4567" required /></div></label>
<label>Password<div className="input-wrap"><LockKeyhole size={16} /><input name="password" type="password" placeholder="8+ characters" minLength="8" required autoComplete="new-password" /><Eye size={15} /></div><small className="field-hint">Use uppercase, lowercase, and a number.</small></label>
{status.error && <p className="form-error" role="alert">{status.error}</p>}
<button className="button button-coral auth-submit" disabled={status.loading}>
{status.loading ? "Creating account…" : "Create account"} <ArrowRight size={16} />
</button>
</form>
<p className="auth-switch">Already have an account? <a href="/login">Log in</a></p>
</>
</AuthLayout>
);
}

View file

@ -0,0 +1,271 @@
import { ArrowRight, Copy, Globe, Server } from "lucide-react";
import { useEffect, useState } from "react";
import { previewDnsSetup } from "../api";
import AuthLayout from "./AuthLayout";
const STORAGE_KEY = "agmail_dns_setup_auth";
function loadStoredAuth() {
try {
const raw = sessionStorage.getItem(STORAGE_KEY);
if (!raw) {
return null;
}
const parsed = JSON.parse(raw);
if (!parsed?.username || !parsed?.password) {
return null;
}
return parsed;
} catch {
return null;
}
}
function DnsRecordTable({ title, records }) {
if (!records?.length) {
return null;
}
async function copyValue(value) {
try {
await navigator.clipboard.writeText(value);
} catch {
/* clipboard may be unavailable */
}
}
return (
<section className="dns-section">
<h3>{title}</h3>
<div className="dns-table-wrap">
<table className="dns-table">
<thead>
<tr>
<th>Type</th>
<th>Host</th>
<th>Value</th>
<th>TTL</th>
</tr>
</thead>
<tbody>
{records.map((record) => {
const rowKey = `${record.record_type}-${record.host}-${record.purpose}`;
const displayValue = record.priority
? `${record.priority} ${record.value}`
: record.value;
return (
<tr key={rowKey}>
<td>{record.record_type}</td>
<td><code>{record.host}</code></td>
<td>
<div className="dns-value-cell">
<code>{displayValue}</code>
<button
type="button"
className="dns-copy"
aria-label="Copy value"
onClick={() => copyValue(displayValue)}
>
<Copy size={14} />
</button>
</div>
</td>
<td>{record.ttl}</td>
</tr>
);
})}
</tbody>
</table>
</div>
</section>
);
}
export default function SmtpCredentialsPage() {
const [auth, setAuth] = useState(() => loadStoredAuth());
const [status, setStatus] = useState({ loading: false, error: "" });
const [result, setResult] = useState(null);
useEffect(() => {
if (auth) {
sessionStorage.setItem(STORAGE_KEY, JSON.stringify(auth));
} else {
sessionStorage.removeItem(STORAGE_KEY);
setResult(null);
}
}, [auth]);
async function handleLogin(event) {
event.preventDefault();
setStatus({ loading: true, error: "" });
const form = new FormData(event.currentTarget);
const nextAuth = {
username: String(form.get("username") || ""),
password: String(form.get("password") || ""),
};
try {
await previewDnsSetup(
{ domain: "example.com", mail_server_ip: "127.0.0.1" },
nextAuth,
);
setAuth(nextAuth);
setStatus({ loading: false, error: "" });
} catch (error) {
setStatus({ loading: false, error: error.message });
}
}
async function handleGenerate(event) {
event.preventDefault();
if (!auth) {
return;
}
setStatus({ loading: true, error: "" });
const form = new FormData(event.currentTarget);
try {
const data = await previewDnsSetup(
{
domain: String(form.get("domain") || ""),
mail_server_ip: String(form.get("mail_server_ip") || ""),
},
auth,
);
setResult(data);
setStatus({ loading: false, error: "" });
} catch (error) {
if (error.message.toLowerCase().includes("credential")) {
setAuth(null);
}
setStatus({ loading: false, error: error.message });
}
}
if (!auth) {
return (
<AuthLayout
eyebrow="Operations"
title={<>DNS setup<br /><em>for mail.</em></>}
description="Sign in to generate Namecheap-ready DNS records for self-hosted inbound and outbound mail."
>
<>
<div className="card-heading">
<h2>DNS setup access</h2>
<p>Restricted to deployment operators.</p>
</div>
<form className="auth-form" onSubmit={handleLogin}>
<label>
Username
<div className="input-wrap">
<Server size={16} />
<input
name="username"
type="text"
placeholder="smtp-credentia"
required
autoComplete="username"
/>
</div>
</label>
<label>
Passkey
<div className="input-wrap">
<Globe size={16} />
<input
name="password"
type="password"
placeholder="Your passkey"
required
autoComplete="current-password"
/>
</div>
</label>
{status.error && (
<p className="form-error" role="alert">{status.error}</p>
)}
<button className="button button-coral auth-submit" disabled={status.loading}>
{status.loading ? "Checking…" : "Continue"} <ArrowRight size={16} />
</button>
</form>
</>
</AuthLayout>
);
}
return (
<AuthLayout
eyebrow="Operations"
title={<>DNS records<br /><em>ready to paste.</em></>}
description="Enter the master domain and this server's public IP. Records are formatted for Namecheap Advanced DNS."
>
<>
<div className="card-heading">
<h2>Generate DNS configuration</h2>
<p>Inbound delivery and outbound authentication records for this mail host.</p>
</div>
<form className="auth-form" onSubmit={handleGenerate}>
<label>
Master domain
<div className="input-wrap">
<Globe size={16} />
<input
name="domain"
type="text"
placeholder="getall.help"
defaultValue={result?.domain || "getall.help"}
required
/>
</div>
</label>
<label>
Mail server public IP
<div className="input-wrap">
<Server size={16} />
<input
name="mail_server_ip"
type="text"
placeholder="203.0.113.10"
defaultValue={result?.mail_server_ip || ""}
required
/>
</div>
</label>
{status.error && (
<p className="form-error" role="alert">{status.error}</p>
)}
<button className="button button-coral auth-submit" disabled={status.loading}>
{status.loading ? "Generating…" : "Generate DNS records"} <ArrowRight size={16} />
</button>
</form>
{result && (
<div className="dns-results">
<p className="dns-summary">
<strong>{result.mail_host}</strong> → {result.mail_server_ip}
{result.app_public_host && (
<> · web UI at <strong>{result.app_public_host}</strong></>
)}
{" · self-hosted inbound + outbound"}
</p>
<ul className="dns-notes">
{result.notes.map((note) => (
<li key={note}>{note}</li>
))}
</ul>
<DnsRecordTable
title="Inbound (MX, A — deliver mail to this host)"
records={result.sections.inbound}
/>
<DnsRecordTable
title="Outbound (SPF, DKIM, DMARC — authenticate sending)"
records={result.sections.outbound}
/>
</div>
)}
<p className="auth-switch">
<button type="button" className="link-button" onClick={() => setAuth(null)}>
Sign out
</button>
</p>
</>
</AuthLayout>
);
}

View file

@ -0,0 +1,283 @@
import {
MoreHorizontal,
Pin,
Plus,
Trash2,
} from "lucide-react";
import { useCallback, useEffect, useState } from "react";
import {
createStickyNote,
deleteStickyNote,
listStickyNotes,
} from "../api";
import bluePin from "../data/blue.png";
import greenPin from "../data/green.png";
import pinkPin from "../data/pink.png";
import yellowPin from "../data/yellow.png";
const NOTE_COLORS = ["yellow", "green", "blue", "pink"];
const PIN_IMAGES = {
yellow: yellowPin,
green: greenPin,
blue: bluePin,
pink: pinkPin,
};
function formatNoteTime(value) {
if (!value) return "";
const date = new Date(value);
if (Number.isNaN(date.getTime())) return "";
return new Intl.DateTimeFormat(undefined, {
month: "short",
day: "numeric",
hour: "numeric",
minute: "2-digit",
}).format(date);
}
function NoteCard({ note, onDelete, menuOpen, onMenuToggle }) {
return (
<article className={`sticky-note sticky-note-${note.color}`}>
<span className="sticky-note-pin" aria-hidden="true">
<img src={PIN_IMAGES[note.color] || yellowPin} alt="" />
</span>
<header className="sticky-note-header">
<h3>{note.title}</h3>
<div className="sticky-note-menu-wrap">
<button
type="button"
className="sticky-note-menu-btn"
aria-label="Note options"
aria-expanded={menuOpen}
onClick={(event) => {
event.stopPropagation();
onMenuToggle(note.id);
}}
>
<MoreHorizontal size={16} />
</button>
{menuOpen && (
<div className="sticky-note-menu">
<button type="button" onClick={() => onDelete(note.id)}>
<Trash2 size={14} /> Delete
</button>
</div>
)}
</div>
</header>
{note.body && <p>{note.body}</p>}
<footer className="sticky-note-footer">
<div className="sticky-note-tags">
{(note.tags.length ? note.tags : ["General"]).map((tag) => (
<span key={tag}>{tag}</span>
))}
</div>
<time dateTime={note.updated_at}>{formatNoteTime(note.updated_at)}</time>
</footer>
</article>
);
}
export default function StickyNotesPanel() {
const token = sessionStorage.getItem("agmail_access_token");
const [notes, setNotes] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const [adding, setAdding] = useState(false);
const [menuOpenId, setMenuOpenId] = useState(null);
const [draft, setDraft] = useState({
title: "",
body: "",
tags: "",
color: "yellow",
});
const [saving, setSaving] = useState(false);
const load = useCallback(async () => {
setLoading(true);
setError("");
try {
const items = await listStickyNotes(token);
setNotes(items);
} catch (loadError) {
setError(loadError.message);
} finally {
setLoading(false);
}
}, [token]);
useEffect(() => {
load();
}, [load]);
useEffect(() => {
function closeMenu() {
setMenuOpenId(null);
}
window.addEventListener("click", closeMenu);
return () => window.removeEventListener("click", closeMenu);
}, []);
async function handleCreate(event) {
event.preventDefault();
if (!draft.title.trim()) return;
setSaving(true);
setError("");
try {
const created = await createStickyNote(
{
title: draft.title.trim(),
body: draft.body.trim(),
tags: draft.tags
.split(",")
.map((tag) => tag.trim())
.filter(Boolean),
color: draft.color,
},
token,
);
setNotes((current) => [created, ...current]);
setDraft({
title: "",
body: "",
tags: "",
color: NOTE_COLORS[(notes.length + 1) % NOTE_COLORS.length],
});
setAdding(false);
} catch (createError) {
setError(createError.message);
} finally {
setSaving(false);
}
}
async function handleDelete(noteId) {
setMenuOpenId(null);
setError("");
try {
await deleteStickyNote(noteId, token);
setNotes((current) => current.filter((note) => note.id !== noteId));
} catch (deleteError) {
setError(deleteError.message);
}
}
function handleBodyKeyDown(event) {
if (event.key !== "Enter" || event.shiftKey) return;
event.preventDefault();
const textarea = event.currentTarget;
const { selectionStart, selectionEnd, value } = textarea;
const insertion = "\n• ";
const nextValue =
value.slice(0, selectionStart) + insertion + value.slice(selectionEnd);
setDraft((current) => ({ ...current, body: nextValue }));
window.requestAnimationFrame(() => {
textarea.selectionStart = selectionStart + insertion.length;
textarea.selectionEnd = selectionStart + insertion.length;
});
}
return (
<aside className="mailbox-notes-pane" aria-label="Sticky notes">
<header className="sticky-notes-header">
<div className="sticky-notes-title">
<Pin size={15} />
<h2>Sticky Notes</h2>
</div>
<div className="sticky-notes-actions">
<button
type="button"
aria-label="Add sticky note"
onClick={() => {
setDraft((current) => ({
...current,
color: NOTE_COLORS[notes.length % NOTE_COLORS.length],
}));
setAdding(true);
}}
>
<Plus size={18} />
</button>
</div>
</header>
<div className="sticky-notes-body">
{loading && <div className="sticky-notes-state">Loading notes…</div>}
{error && !loading && (
<div className="sticky-notes-state error" role="alert">{error}</div>
)}
{!loading && notes.map((note) => (
<NoteCard
key={note.id}
note={note}
menuOpen={menuOpenId === note.id}
onMenuToggle={(id) => setMenuOpenId((current) => (current === id ? null : id))}
onDelete={handleDelete}
/>
))}
{adding ? (
<form className="sticky-note-form" onSubmit={handleCreate}>
<input
type="text"
value={draft.title}
onChange={(event) => setDraft((current) => ({ ...current, title: event.target.value }))}
placeholder="Note title"
autoFocus
required
/>
<textarea
value={draft.body}
onChange={(event) => setDraft((current) => ({ ...current, body: event.target.value }))}
onKeyDown={handleBodyKeyDown}
placeholder="Jot down a quick note…"
rows={3}
/>
<input
type="text"
value={draft.tags}
onChange={(event) => setDraft((current) => ({ ...current, tags: event.target.value }))}
placeholder="Tags (comma separated)"
/>
<div className="sticky-note-colors">
{NOTE_COLORS.map((color) => (
<button
key={color}
type="button"
className={`sticky-note-color${draft.color === color ? " active" : ""}`}
data-color={color}
aria-label={`${color} note`}
onClick={() => setDraft((current) => ({ ...current, color }))}
/>
))}
</div>
<div className="sticky-note-form-actions">
<button type="button" onClick={() => setAdding(false)} disabled={saving}>
Cancel
</button>
<button type="submit" disabled={saving || !draft.title.trim()}>
{saving ? "Saving…" : "Save note"}
</button>
</div>
</form>
) : (
<button
type="button"
className="sticky-note-add"
onClick={() => {
setDraft((current) => ({
...current,
color: NOTE_COLORS[notes.length % NOTE_COLORS.length],
}));
setAdding(true);
}}
>
<Plus size={20} />
<span>Add sticky note</span>
<small>Jot down a quick note…</small>
</button>
)}
</div>
</aside>
);
}

View file

@ -0,0 +1,65 @@
import {
ArrowRight, Bot, Check, ChevronDown, Clock3, Inbox, Menu,
Paperclip, Search, Send, ShieldCheck, Sparkles,
} from "lucide-react";
import Brand from "./Brand";
const mail = [
{ sender: "Research agent", subject: "Market brief is ready", time: "10:42", color: "coral" },
{ sender: "Maya Chen", subject: "Re: Product direction", time: "9:18", color: "blue" },
{ sender: "Calendar", subject: "Design review in 30 minutes", time: "8:30", color: "gold" },
];
export default function WebLanding() {
return (
<main className="landing">
<nav className="nav shell">
<Brand />
<div className="nav-links"><a href="#product">Product</a><a href="#security">Security</a><a href="#about">About</a></div>
<div className="nav-actions"><a className="button button-quiet" href="/login">Log in</a><a className="button button-dark" href="/signup">Create account <ArrowRight size={15} /></a></div>
</nav>
<section className="hero shell">
<div className="hero-copy">
<div className="eyebrow"><Sparkles size={14} /> Built for you and your agents</div>
<h1>Email that works<br /><em>at your speed.</em></h1>
<p>A calm, intelligent inbox where you and your AI agents can send, receive, organize, and get work done.</p>
<div className="hero-actions"><a className="button button-coral" href="/signup">Get your @agmail <ArrowRight size={17} /></a><a className="button button-outline" href="#product">See how it works</a></div>
<div className="trust-line"><span><Check size={13} /> Free to start</span><span><Check size={13} /> No card required</span></div>
</div>
<div className="product-stage" aria-label="Agmail inbox preview">
<div className="orb orb-one" /><div className="orb orb-two" />
<div className="inbox-window">
<aside className="inbox-sidebar">
<div className="mini-brand"><Brand compact /></div>
<button className="compose"><span>+</span></button>
<div className="side-icons"><span className="active"><Inbox size={18} /></span><span><Send size={18} /></span><span><Clock3 size={18} /></span></div>
<div className="avatar">AK</div>
</aside>
<div className="mail-panel">
<header className="mail-toolbar"><div><small>Good morning,</small><strong>Alex</strong></div><div className="toolbar-actions"><Search size={17} /><Menu size={17} /></div></header>
<div className="inbox-heading"><div><h3>Inbox</h3><span>12</span></div><button>Priority <ChevronDown size={13} /></button></div>
<div className="agent-summary"><div className="agent-icon"><Bot size={19} /></div><div><strong>Your agent handled 8 emails</strong><p>3 replies drafted · 2 tasks completed</p></div><ArrowRight size={16} /></div>
<div className="mail-list">
{mail.map((item, index) => (
<article className={index === 0 ? "mail-item selected" : "mail-item"} key={item.sender}>
<span className={`sender-avatar ${item.color}`}>{item.sender[0]}</span><div><strong>{item.sender}</strong><p>{item.subject}</p></div><time>{item.time}</time>
</article>
))}
</div>
<div className="quick-reply"><Paperclip size={16} /><span>Ask your agent to draft a reply…</span><button><ArrowRight size={15} /></button></div>
</div>
</div>
<div className="floating-note"><span><Check size={14} /></span><div><strong>Follow-up sent</strong><small>Handled by your agent</small></div></div>
</div>
</section>
<section className="feature-strip shell" id="product">
<div className="feature-intro"><span>One inbox. Less noise.</span><h2>Made for the way work is changing.</h2></div>
<div className="feature-grid">
<article><span className="feature-icon peach"><Bot size={21} /></span><h3>Agent-ready</h3><p>Give every agent a secure address and clear permissions.</p></article>
<article><span className="feature-icon cyan"><Sparkles size={21} /></span><h3>Quietly intelligent</h3><p>Summaries, sorting, and replies—without the clutter.</p></article>
<article id="security"><span className="feature-icon violet"><ShieldCheck size={21} /></span><h3>Private by design</h3><p>Your conversations stay encrypted and in your control.</p></article>
</div>
</section>
</main>
);
}

View file

@ -0,0 +1,678 @@
{
"AA": "#3E0C0C",
"AB": "#3F100C",
"AC": "#40150B",
"AD": "#41190B",
"AE": "#421E0B",
"AF": "#43230A",
"AG": "#44280A",
"AH": "#452E0A",
"AI": "#463409",
"AJ": "#473A09",
"AK": "#484008",
"AL": "#494708",
"AM": "#464908",
"AN": "#414A07",
"AO": "#3B4B07",
"AP": "#364C07",
"AQ": "#304E06",
"AR": "#2A4F06",
"AS": "#245005",
"AT": "#1E5105",
"AU": "#175204",
"AV": "#105304",
"AW": "#085404",
"AX": "#035505",
"AY": "#03560C",
"AZ": "#025713",
"BA": "#40180C",
"BB": "#401D0C",
"BC": "#41210B",
"BD": "#42260B",
"BE": "#432C0B",
"BF": "#44310A",
"BG": "#45370A",
"BH": "#463D0A",
"BI": "#474309",
"BJ": "#474809",
"BK": "#424909",
"BL": "#3D4A08",
"BM": "#384B08",
"BN": "#324C08",
"BO": "#2D4D07",
"BP": "#274E07",
"BQ": "#204F06",
"BR": "#1A5006",
"BS": "#135105",
"BT": "#0C5205",
"BU": "#055305",
"BV": "#04540B",
"BW": "#045512",
"BX": "#035619",
"BY": "#035820",
"BZ": "#025928",
"CA": "#41250C",
"CB": "#422A0C",
"CC": "#432F0C",
"CD": "#44340B",
"CE": "#453A0B",
"CF": "#46400B",
"CG": "#47460A",
"CH": "#43480A",
"CI": "#3E490A",
"CJ": "#394A09",
"CK": "#344B09",
"CL": "#2F4C08",
"CM": "#294D08",
"CN": "#234E08",
"CO": "#1D4F07",
"CP": "#175007",
"CQ": "#105106",
"CR": "#095206",
"CS": "#065309",
"CT": "#055410",
"CU": "#055517",
"CV": "#04561E",
"CW": "#045725",
"CX": "#03582D",
"CY": "#035935",
"CZ": "#025A3D",
"DA": "#42320D",
"DB": "#43370C",
"DC": "#443D0C",
"DD": "#45430C",
"DE": "#44460B",
"DF": "#3F470B",
"DG": "#3B480B",
"DH": "#36490A",
"DI": "#314A0A",
"DJ": "#2B4B09",
"DK": "#264C09",
"DL": "#204D09",
"DM": "#1A4E08",
"DN": "#134F08",
"DO": "#0D5007",
"DP": "#075108",
"DQ": "#07520E",
"DR": "#065315",
"DS": "#06541C",
"DT": "#055523",
"DU": "#05572A",
"DV": "#045832",
"DW": "#045939",
"DX": "#035A42",
"DY": "#035B4A",
"DZ": "#025C53",
"EA": "#44400D",
"EB": "#44450D",
"EC": "#40460C",
"ED": "#3C470C",
"EE": "#37480C",
"EF": "#32490B",
"EG": "#2D4A0B",
"EH": "#284B0A",
"EI": "#224C0A",
"EJ": "#1D4D0A",
"EK": "#174E09",
"EL": "#104F09",
"EM": "#0A5008",
"EN": "#08510D",
"EO": "#085213",
"EP": "#07531A",
"EQ": "#075420",
"ER": "#065527",
"ES": "#06562F",
"ET": "#055736",
"EU": "#05583E",
"EV": "#045946",
"EW": "#045A4E",
"EX": "#035B57",
"EY": "#03595D",
"EZ": "#02525E",
"FA": "#3D450D",
"FB": "#38460D",
"FC": "#34470D",
"FD": "#2F480C",
"FE": "#2A490C",
"FF": "#254A0B",
"FG": "#1F4B0B",
"FH": "#1A4C0B",
"FI": "#144D0A",
"FJ": "#0D4E0A",
"FK": "#094F0C",
"FL": "#095012",
"FM": "#095118",
"FN": "#08521E",
"FO": "#085325",
"FP": "#07542C",
"FQ": "#075633",
"FR": "#06573B",
"FS": "#065842",
"FT": "#05594A",
"FU": "#055A53",
"FV": "#045A5B",
"FW": "#04545C",
"FX": "#034D5D",
"FY": "#03465E",
"FZ": "#023E5F",
"GA": "#31470D",
"GB": "#2C480D",
"GC": "#27490D",
"GD": "#224A0C",
"GE": "#1D4B0C",
"GF": "#174C0C",
"GG": "#114D0B",
"GH": "#0B4E0B",
"GI": "#0A4F11",
"GJ": "#0A5016",
"GK": "#0A511D",
"GL": "#095223",
"GM": "#09532A",
"GN": "#085430",
"GO": "#085538",
"GP": "#07563F",
"GQ": "#075747",
"GR": "#06584F",
"GS": "#065957",
"GT": "#06555A",
"GU": "#054F5B",
"GV": "#05485D",
"GW": "#04415E",
"GX": "#04395F",
"GY": "#033160",
"GZ": "#022961",
"HA": "#24480E",
"HB": "#1F490D",
"HC": "#1A4A0D",
"HD": "#144B0D",
"HE": "#0E4C0C",
"HF": "#0C4D10",
"HG": "#0B4E15",
"HH": "#0B4F1B",
"HI": "#0B5021",
"HJ": "#0A5127",
"HK": "#0A522E",
"HL": "#095335",
"HM": "#09543C",
"HN": "#085643",
"HO": "#08574B",
"HP": "#085853",
"HQ": "#075759",
"HR": "#07505A",
"HS": "#064A5B",
"HT": "#06435C",
"HU": "#053C5D",
"HV": "#05345E",
"HW": "#042D5F",
"HX": "#042560",
"HY": "#031C62",
"HZ": "#031463",
"IA": "#174A0E",
"IB": "#124B0E",
"IC": "#0D4C0F",
"ID": "#0D4D14",
"IE": "#0C4E19",
"IF": "#0C4F1F",
"IG": "#0C5025",
"IH": "#0B512B",
"II": "#0B5232",
"IJ": "#0A5339",
"IK": "#0A5440",
"IL": "#0A5547",
"IM": "#09564F",
"IN": "#095756",
"IO": "#085258",
"IP": "#084C59",
"IQ": "#07455A",
"IR": "#073E5B",
"IS": "#06375D",
"IT": "#06305E",
"IU": "#05285F",
"IV": "#052060",
"IW": "#041861",
"IX": "#041062",
"IY": "#030763",
"IZ": "#070364",
"JA": "#0E4B13",
"JB": "#0E4C18",
"JC": "#0E4D1E",
"JD": "#0D4E23",
"JE": "#0D4F29",
"JF": "#0C502F",
"JG": "#0C5136",
"JH": "#0B523D",
"JI": "#0B5344",
"JJ": "#0B544B",
"JK": "#0A5552",
"JL": "#0A5357",
"JM": "#094D58",
"JN": "#094759",
"JO": "#08405A",
"JP": "#083A5B",
"JQ": "#07335C",
"JR": "#072B5D",
"JS": "#06245E",
"JT": "#061C5F",
"JU": "#051460",
"JV": "#050B62",
"JW": "#060463",
"JX": "#0E0464",
"JY": "#160365",
"JZ": "#1F0366",
"KA": "#0F4D22",
"KB": "#0E4E27",
"KC": "#0E4F2D",
"KD": "#0D5033",
"KE": "#0D513A",
"KF": "#0D5240",
"KG": "#0C5347",
"KH": "#0C544E",
"KI": "#0B5455",
"KJ": "#0B4E56",
"KK": "#0A4957",
"KL": "#0A4258",
"KM": "#093C59",
"KN": "#09355A",
"KO": "#082E5B",
"KP": "#08275C",
"KQ": "#07205E",
"KR": "#07185F",
"KS": "#061060",
"KT": "#060761",
"KU": "#0C0562",
"KV": "#140563",
"KW": "#1C0464",
"KX": "#250465",
"KY": "#2D0367",
"KZ": "#360368",
"LA": "#0F4E31",
"LB": "#0E4F37",
"LC": "#0E503D",
"LD": "#0E5144",
"LE": "#0D524B",
"LF": "#0D5352",
"LG": "#0C4F54",
"LH": "#0C4A55",
"LI": "#0B4456",
"LJ": "#0B3E58",
"LK": "#0B3859",
"LL": "#0A315A",
"LM": "#0A2A5B",
"LN": "#09235C",
"LO": "#091C5D",
"LP": "#08145E",
"LQ": "#080C5F",
"LR": "#0A0760",
"LS": "#120761",
"LT": "#1A0663",
"LU": "#220564",
"LV": "#2A0565",
"LW": "#330466",
"LX": "#3C0467",
"LY": "#450368",
"LZ": "#4F0369",
"MA": "#0F5041",
"MB": "#0F5147",
"MC": "#0E524E",
"MD": "#0E5053",
"ME": "#0D4B54",
"MF": "#0D4655",
"MG": "#0D4056",
"MH": "#0C3A57",
"MI": "#0C3458",
"MJ": "#0B2D59",
"MK": "#0B265A",
"ML": "#0A1F5B",
"MM": "#0A185C",
"MN": "#09105D",
"MO": "#09095F",
"MP": "#100860",
"MQ": "#180861",
"MR": "#1F0762",
"MS": "#280763",
"MT": "#300664",
"MU": "#390665",
"MV": "#410566",
"MW": "#4B0468",
"MX": "#540469",
"MY": "#5E036A",
"MZ": "#68036B",
"NA": "#0F5151",
"NB": "#0F4C52",
"NC": "#0F4753",
"ND": "#0E4254",
"NE": "#0E3C55",
"NF": "#0D3656",
"NG": "#0D3057",
"NH": "#0C2958",
"NI": "#0C235A",
"NJ": "#0B1C5B",
"NK": "#0B145C",
"NL": "#0A0D5D",
"NM": "#0F0A5E",
"NN": "#16095F",
"NO": "#1D0960",
"NP": "#250861",
"NQ": "#2D0862",
"NR": "#350764",
"NS": "#3E0765",
"NT": "#470666",
"NU": "#500667",
"NV": "#590568",
"NW": "#630569",
"NX": "#6A0468",
"NY": "#6C0360",
"NZ": "#6D0357",
"OA": "#104353",
"OB": "#0F3E54",
"OC": "#0F3855",
"OD": "#0E3256",
"OE": "#0E2C57",
"OF": "#0D2658",
"OG": "#0D1F59",
"OH": "#0D185A",
"OI": "#0C115B",
"OJ": "#0D0C5C",
"OK": "#140B5D",
"OL": "#1B0B5E",
"OM": "#230A5F",
"ON": "#2A0A61",
"OO": "#320962",
"OP": "#3A0863",
"OQ": "#430864",
"OR": "#4C0765",
"OS": "#550766",
"OT": "#5E0667",
"OU": "#680669",
"OV": "#6A0562",
"OW": "#6B055A",
"OX": "#6C0451",
"OY": "#6D0349",
"OZ": "#6F0340",
"PA": "#103554",
"PB": "#102F55",
"PC": "#0F2956",
"PD": "#0F2357",
"PE": "#0E1C58",
"PF": "#0E1559",
"PG": "#0D0E5A",
"PH": "#130D5B",
"PI": "#1A0C5D",
"PJ": "#210C5E",
"PK": "#280B5F",
"PL": "#2F0B60",
"PM": "#370A61",
"PN": "#3F0A62",
"PO": "#480963",
"PP": "#510964",
"PQ": "#5A0866",
"PR": "#630867",
"PS": "#680763",
"PT": "#69065C",
"PU": "#6A0654",
"PV": "#6B054C",
"PW": "#6D0543",
"PX": "#6E043A",
"PY": "#6F0331",
"PZ": "#700327",
"QA": "#102655",
"QB": "#101F56",
"QC": "#0F1958",
"QD": "#0F1259",
"QE": "#120E5A",
"QF": "#180E5B",
"QG": "#1F0D5C",
"QH": "#260D5D",
"QI": "#2D0C5E",
"QJ": "#340C5F",
"QK": "#3C0B60",
"QL": "#440B61",
"QM": "#4D0A63",
"QN": "#550A64",
"QO": "#5E0965",
"QP": "#660965",
"QQ": "#67085D",
"QR": "#680856",
"QS": "#6A074E",
"QT": "#6B0646",
"QU": "#6C063D",
"QV": "#6D0535",
"QW": "#6E052C",
"QX": "#700422",
"QY": "#710419",
"QZ": "#72030E",
"RA": "#111657",
"RB": "#111058",
"RC": "#171059",
"RD": "#1D0F5A",
"RE": "#240F5B",
"RF": "#2B0E5C",
"RG": "#320E5D",
"RH": "#390D5F",
"RI": "#410D60",
"RJ": "#490C61",
"RK": "#510C62",
"RL": "#5A0B63",
"RM": "#620B64",
"RN": "#650A5F",
"RO": "#660958",
"RP": "#680950",
"RQ": "#690849",
"RR": "#6A0841",
"RS": "#6B0738",
"RT": "#6C0730",
"RU": "#6E0627",
"RV": "#6F051D",
"RW": "#700514",
"RX": "#71040A",
"RY": "#720804",
"RZ": "#741103",
"SA": "#1C1158",
"SB": "#221059",
"SC": "#29105B",
"SD": "#2F0F5C",
"SE": "#360F5D",
"SF": "#3E0E5E",
"SG": "#450E5F",
"SH": "#4D0D60",
"SI": "#550D61",
"SJ": "#5E0C62",
"SK": "#630C60",
"SL": "#650B5A",
"SM": "#660B52",
"SN": "#670A4B",
"SO": "#680A43",
"SP": "#69093B",
"SQ": "#6A0833",
"SR": "#6C082B",
"SS": "#6D0722",
"ST": "#6E0718",
"SU": "#6F060F",
"SV": "#700605",
"SW": "#720F05",
"SX": "#470F03",
"SY": "#481502",
"SZ": "#491B02",
"TA": "#2D115A",
"TB": "#34115B",
"TC": "#3B105C",
"TD": "#42105D",
"TE": "#490F5E",
"TF": "#510F5F",
"TG": "#590E60",
"TH": "#620E61",
"TI": "#630D5B",
"TJ": "#640D54",
"TK": "#650C4D",
"TL": "#660B46",
"TM": "#670B3E",
"TN": "#680A37",
"TO": "#6A0A2E",
"TP": "#6B0926",
"TQ": "#6C091D",
"TR": "#6D0814",
"TS": "#6E070A",
"TT": "#700D07",
"TU": "#460E04",
"TV": "#471303",
"TW": "#481903",
"TX": "#492003",
"TY": "#4A2602",
"TZ": "#4B2D02",
"UA": "#3F115B",
"UB": "#46115C",
"UC": "#4D105D",
"UD": "#55105F",
"UE": "#5D0F60",
"UF": "#610F5C",
"UG": "#620E56",
"UH": "#630E4F",
"UI": "#640D48",
"UJ": "#650D41",
"UK": "#670C3A",
"UL": "#680C32",
"UM": "#690B2A",
"UN": "#6A0A21",
"UO": "#6B0A19",
"UP": "#6C090F",
"UQ": "#6E0B09",
"UR": "#6F1408",
"US": "#461205",
"UT": "#471804",
"UU": "#481E04",
"UV": "#492404",
"UW": "#4A2A03",
"UX": "#4B3103",
"UY": "#4C3802",
"UZ": "#4D3F02",
"VA": "#51125D",
"VB": "#59115E",
"VC": "#5F115D",
"VD": "#601057",
"VE": "#611051",
"VF": "#620F4A",
"VG": "#630F44",
"VH": "#650E3C",
"VI": "#660D35",
"VJ": "#670D2D",
"VK": "#680C25",
"VL": "#690C1D",
"VM": "#6A0B14",
"VN": "#6C0B0B",
"VO": "#6D120A",
"VP": "#451006",
"VQ": "#451606",
"VR": "#461C05",
"VS": "#472205",
"VT": "#482804",
"VU": "#492E04",
"VV": "#4A3504",
"VW": "#4B3C03",
"VX": "#4C4303",
"VY": "#4D4B02",
"VZ": "#4B4E02",
"WA": "#5E1258",
"WB": "#5F1152",
"WC": "#60114C",
"WD": "#621046",
"WE": "#63103F",
"WF": "#640F38",
"WG": "#650F31",
"WH": "#660E29",
"WI": "#670E21",
"WJ": "#680D19",
"WK": "#6A0D10",
"WL": "#6B100C",
"WM": "#430F07",
"WN": "#441407",
"WO": "#451A06",
"WP": "#462006",
"WQ": "#472606",
"WR": "#482C05",
"WS": "#493205",
"WT": "#4A3905",
"WU": "#4B4004",
"WV": "#4C4704",
"WW": "#4C4D03",
"WX": "#464E03",
"WY": "#404F02",
"WZ": "#3A5002",
"XA": "#601248",
"XB": "#611241",
"XC": "#62113B",
"XD": "#631134",
"XE": "#64102D",
"XF": "#651025",
"XG": "#660F1D",
"XH": "#680E15",
"XI": "#690F0E",
"XJ": "#420E08",
"XK": "#431308",
"XL": "#441808",
"XM": "#451E07",
"XN": "#462307",
"XO": "#472907",
"XP": "#482F06",
"XQ": "#493606",
"XR": "#4A3C05",
"XS": "#4B4305",
"XT": "#4C4A05",
"XU": "#484D04",
"XV": "#424E04",
"XW": "#3C4F03",
"XX": "#365003",
"XY": "#305103",
"XZ": "#295202",
"YA": "#611237",
"YB": "#621230",
"YC": "#631129",
"YD": "#651121",
"YE": "#66101A",
"YF": "#671012",
"YG": "#410D09",
"YH": "#421209",
"YI": "#431709",
"YJ": "#431C08",
"YK": "#442108",
"YL": "#452708",
"YM": "#462D07",
"YN": "#473307",
"YO": "#483907",
"YP": "#494006",
"YQ": "#4A4706",
"YR": "#494B06",
"YS": "#434C05",
"YT": "#3E4D05",
"YU": "#384E04",
"YV": "#324F04",
"YW": "#2C5003",
"YX": "#255103",
"YY": "#1E5203",
"YZ": "#175402",
"ZA": "#631325",
"ZB": "#64121E",
"ZC": "#651216",
"ZD": "#3F0C0B",
"ZE": "#40110A",
"ZF": "#41160A",
"ZG": "#421B0A",
"ZH": "#432009",
"ZI": "#442509",
"ZJ": "#452B09",
"ZK": "#463008",
"ZL": "#473608",
"ZM": "#483D08",
"ZN": "#494307",
"ZO": "#4A4A07",
"ZP": "#454B06",
"ZQ": "#3F4C06",
"ZR": "#3A4D06",
"ZS": "#344E05",
"ZT": "#2E4F05",
"ZU": "#285004",
"ZV": "#215104",
"ZW": "#1A5204",
"ZX": "#135303",
"ZY": "#0C5403",
"ZZ": "#045502"
}

BIN
frontend/src/data/blue.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 212 KiB

BIN
frontend/src/data/green.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 220 KiB

BIN
frontend/src/data/pink.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 218 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 205 KiB

10
frontend/src/main.jsx Normal file
View file

@ -0,0 +1,10 @@
import React from "react";
import ReactDOM from "react-dom/client";
import App from "./App";
import "./styles.css";
ReactDOM.createRoot(document.getElementById("root")).render(
<React.StrictMode>
<App />
</React.StrictMode>,
);

453
frontend/src/styles.css Normal file
View file

@ -0,0 +1,453 @@
@import url("https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;600;700&family=Manrope:wght@400;500;600;700&display=swap");
:root { font-family:"DM Sans",sans-serif; color:#080821; background:#fffdfb; font-synthesis:none; --ink:#080821; --muted:#67677a; --coral:#ff806c; --blush:#fff3ef; --line:#eee9e5; }
* { box-sizing:border-box; }
html { scroll-behavior:smooth; }
body { margin:0; min-width:320px; }
button,a { font:inherit; }
button { cursor:pointer; }
a { color:inherit; text-decoration:none; }
.shell { width:min(1180px,calc(100% - 64px)); margin-inline:auto; }
.mobile-view { display:none; }
.brand { display:inline-flex; align-items:center; gap:10px; font:700 20px "Manrope"; letter-spacing:-.7px; }
.brand-mark { width:31px; height:29px; position:relative; display:block; background:var(--ink); border-radius:9px 9px 11px 11px; transform:rotate(-4deg); box-shadow:inset 0 -2px 0 #ffffff12; }
.robot-eyes { position:absolute; top:8px; left:8px; display:flex; gap:6px; transform:rotate(4deg); }
.robot-eyes i { width:4px; height:5px; display:block; border-radius:50%; background:white; }
.robot-smile { width:14px; height:7px; position:absolute; left:9px; bottom:6px; border:2px solid white; border-top:0; border-radius:0 0 10px 10px; transform:rotate(4deg); }
.brand-compact { line-height:0; }
.nav { height:88px; display:flex; align-items:center; justify-content:space-between; }
.nav-links,.nav-actions,.hero-actions,.trust-line { display:flex; align-items:center; }
.nav-links { gap:38px; margin-left:80px; color:#4f4f62; font-size:14px; }
.nav-actions,.hero-actions { gap:10px; }
.button { min-height:43px; padding:0 19px; display:inline-flex; align-items:center; justify-content:center; gap:9px; border:0; border-radius:11px; font-size:14px; font-weight:600; transition:.2s; }
.button:hover { transform:translateY(-2px); }
.button-quiet { background:transparent; }
.button-dark { color:white; background:var(--ink); box-shadow:0 8px 24px #08082122; }
.button-coral { min-height:51px; padding-inline:24px; color:var(--ink); background:var(--coral); box-shadow:0 12px 25px #ff806c47; }
.button-outline { min-height:51px; color:var(--ink); border:1px solid #dcd7d3; background:#ffffffbf; }
.hero { min-height:660px; padding:36px 0 70px; display:grid; grid-template-columns:.88fr 1.12fr; align-items:center; gap:58px; }
.eyebrow { width:max-content; padding:7px 11px; display:flex; align-items:center; gap:7px; border:1px solid #f0ded7; border-radius:99px; background:#fff9f6; color:#6e5d58; font-size:12px; font-weight:600; }
h1 { margin:23px 0 20px; font:600 clamp(52px,5.2vw,76px)/.99 "Manrope"; letter-spacing:-4.6px; }
h1 em { color:var(--coral); font-style:normal; font-weight:500; }
.hero-copy>p { max-width:500px; color:var(--muted); font-size:17px; line-height:1.65; }
.hero-actions { margin-top:31px; }
.trust-line { gap:20px; margin-top:22px; color:#888795; font-size:12px; }
.trust-line span,.mobile-benefits span { display:flex; align-items:center; gap:5px; }
.trust-line svg,.mobile-benefits svg { color:#49aa87; }
.product-stage { min-height:540px; position:relative; display:grid; place-items:center; }
.product-stage:before { content:""; position:absolute; inset:1% -3% 0; border-radius:48% 52% 38% 45%; background:linear-gradient(145deg,#ffe1cd,#f6efc8 54%,#d6f3f4); transform:rotate(-2deg); }
.orb { position:absolute; border-radius:50%; opacity:.5; }
.orb-one { width:90px; height:90px; top:0; right:20px; background:#ffcfc6; }
.orb-two { width:70px; height:70px; bottom:0; left:0; background:#b8eef2; }
.inbox-window { width:94%; height:445px; z-index:2; display:grid; grid-template-columns:62px 1fr; overflow:hidden; border:7px solid #ffffffbf; border-radius:22px; background:white; box-shadow:0 34px 70px #312a3c33; transform:rotate(1.2deg); }
.inbox-sidebar { padding:18px 0 14px; display:flex; flex-direction:column; align-items:center; color:white; background:var(--ink); }
.mini-brand .brand-mark { width:25px; height:22px; background:white; }
.mini-brand .robot-eyes { top:6px; left:6px; gap:5px; }.mini-brand .robot-eyes i { width:3px; height:4px; background:var(--ink); }
.mini-brand .robot-smile { width:11px; height:5px; left:7px; bottom:4px; border-color:var(--ink); }
.compose { width:34px; height:34px; margin-top:34px; border:0; border-radius:10px; background:var(--coral); font-size:23px; }
.side-icons { margin-top:25px; display:grid; gap:12px; }
.side-icons span { width:36px; height:36px; display:grid; place-items:center; border-radius:10px; color:#9594a8; }
.side-icons .active { color:white; background:#ffffff1f; }
.avatar { width:30px; height:30px; margin-top:auto; display:grid; place-items:center; border-radius:50%; background:#ffe1d5; color:#37201c; font-size:10px; font-weight:700; }
.mail-panel { min-width:0; padding:22px 25px 17px; background:#fffefd; }
.mail-toolbar,.inbox-heading { display:flex; justify-content:space-between; align-items:center; }
.mail-toolbar div:first-child { display:grid; }
.mail-toolbar small { color:#9995a0; font-size:9px; }
.mail-toolbar strong { font-size:13px; }
.toolbar-actions { display:flex; gap:17px; color:#737181; }
.inbox-heading { margin-top:21px; }
.inbox-heading>div { display:flex; align-items:center; gap:8px; }
.inbox-heading h3 { margin:0; font:600 22px "Manrope"; }
.inbox-heading span { width:20px; height:20px; display:grid; place-items:center; border-radius:7px; background:var(--blush); color:var(--coral); font-size:9px; }
.inbox-heading button { display:flex; align-items:center; gap:4px; border:0; background:transparent; color:#777380; font-size:10px; }
.agent-summary { margin-top:14px; padding:12px; display:grid; grid-template-columns:36px 1fr auto; align-items:center; gap:11px; border:1px solid #ffddd5; border-radius:12px; background:#fff1ed; }
.agent-icon { width:34px; height:34px; display:grid; place-items:center; border-radius:10px; background:var(--ink); color:white; }
.agent-summary strong { font-size:11px; }
.agent-summary p { margin:3px 0 0; color:#81777a; font-size:8px; }
.mail-list { margin-top:9px; }
.mail-item,.mobile-mail { display:grid; grid-template-columns:35px 1fr auto; align-items:center; gap:10px; border-bottom:1px solid #f2eeeb; }
.mail-item { padding:10px 8px; }
.mail-item.selected { margin-inline:-5px; padding-inline:13px; border-radius:10px; background:#fcf9f7; }
.sender-avatar { width:31px; height:31px; display:grid; place-items:center; border-radius:50%; font-size:10px; font-weight:700; }
.coral { background:#ffe0d8; color:#bd4e3b; }.blue { background:#daf2f4; color:#247f87; }.gold { background:#f9e9b9; color:#9a741b; }
.mail-item strong { display:block; font-size:10px; }.mail-item p { margin:3px 0 0; color:#84808b; font-size:8px; }.mail-item time { color:#aaa6ac; font-size:8px; }
.quick-reply { height:38px; margin-top:9px; padding:0 8px 0 12px; display:grid; grid-template-columns:auto 1fr auto; align-items:center; gap:8px; border:1px solid #eee8e4; border-radius:11px; color:#aaa3aa; font-size:9px; }
.quick-reply button { width:25px; height:25px; display:grid; place-items:center; border:0; border-radius:8px; background:var(--ink); color:white; }
.floating-note { position:absolute; z-index:4; right:-12px; bottom:35px; padding:11px 15px 11px 11px; display:flex; align-items:center; gap:9px; border-radius:12px; background:white; box-shadow:0 16px 32px #1e192829; transform:rotate(-2deg); }
.floating-note>span { width:27px; height:27px; display:grid; place-items:center; border-radius:50%; background:#dff5ed; color:#3a9877; }
.floating-note div { display:grid; }.floating-note strong { font-size:10px; }.floating-note small { color:#98939b; font-size:8px; }
.feature-strip { padding:74px 0 90px; display:grid; grid-template-columns:.75fr 1.25fr; gap:70px; border-top:1px solid var(--line); }
.feature-intro>span { color:var(--coral); font-size:12px; font-weight:700; text-transform:uppercase; letter-spacing:1.2px; }
.feature-intro h2 { margin:13px 0 0; font:600 34px/1.17 "Manrope"; letter-spacing:-1.7px; }
.feature-grid { display:grid; grid-template-columns:repeat(3,1fr); gap:28px; }
.feature-icon { width:43px; height:43px; display:grid; place-items:center; border-radius:13px; }
.peach { background:#ffe6dd; color:#c85e49; }.cyan { background:#ddf3f4; color:#317d83; }.violet { background:#e9e5f8; color:#595081; }
.feature-grid h3 { margin:17px 0 7px; font-size:15px; }.feature-grid p { margin:0; color:#777482; font-size:12px; line-height:1.6; }
.auth-page { min-height:100vh; position:relative; overflow:hidden; background:linear-gradient(135deg,#fffdfb 0%,#fff9f5 48%,#f5f4df 100%); }
.auth-nav { width:min(1180px,calc(100% - 64px)); height:86px; margin:auto; display:flex; align-items:center; justify-content:space-between; position:relative; z-index:2; }
.back-link { display:flex; align-items:center; gap:7px; color:#74717c; font-size:13px; font-weight:600; }
.auth-shell { width:min(1050px,calc(100% - 64px)); min-height:calc(100vh - 86px); margin:auto; padding:42px 0 70px; display:grid; grid-template-columns:.85fr 1.15fr; align-items:center; gap:95px; position:relative; z-index:1; }
.auth-story h1 { margin:21px 0; font-size:clamp(48px,5vw,67px); }
.auth-story>p { max-width:390px; color:var(--muted); font-size:16px; line-height:1.65; }
.auth-points { margin-top:31px; display:grid; gap:12px; color:#5f5d69; font-size:12px; }
.auth-points span { display:flex; align-items:center; gap:9px; }.auth-points svg { color:var(--coral); }
.auth-card { padding:37px; border:1px solid #eee7e1; border-radius:24px; background:#ffffffeb; box-shadow:0 30px 75px #352b3b1c; backdrop-filter:blur(12px); }
.card-heading>span { color:var(--coral); font-size:10px; font-weight:700; letter-spacing:1.1px; text-transform:uppercase; }
.card-heading h2,.auth-success h2 { margin:8px 0 7px; font:600 25px "Manrope"; letter-spacing:-1px; }
.card-heading p,.auth-success p { margin:0; color:#85818b; font-size:12px; line-height:1.5; }
.auth-form { margin-top:26px; display:grid; gap:17px; }
.auth-form label,.auth-form legend { color:#393745; font-size:11px; font-weight:700; }
.auth-form fieldset { margin:0; padding:0; border:0; }
.auth-form legend { margin-bottom:9px; }
.input-wrap { height:48px; margin-top:7px; padding:0 14px; display:flex; align-items:center; gap:10px; border:1px solid #e4dfdc; border-radius:11px; background:white; color:#a19ba2; transition:.2s; }
.input-wrap:focus-within { border-color:var(--coral); box-shadow:0 0 0 3px #ff806c18; }
.input-wrap input { min-width:0; flex:1; border:0; outline:0; background:transparent; color:var(--ink); font-size:13px; }
.input-wrap input::placeholder { color:#aaa5ab; }
.account-types { display:grid; grid-template-columns:1fr 1fr; gap:10px; }
.type-card { min-height:74px; padding:13px; display:grid; grid-template-columns:20px 1fr; align-items:center; gap:10px; border:1px solid #e5e0dc; border-radius:12px; background:white; cursor:pointer; }
.type-card.selected { border-color:var(--coral); background:#fff6f2; box-shadow:0 0 0 2px #ff806c12; }
.type-card input { position:absolute; opacity:0; pointer-events:none; }
.type-card>span { display:grid; gap:3px; }.type-card strong { font-size:11px; }.type-card small { color:#97919a; font-size:8px; font-weight:500; line-height:1.3; }
.type-card svg { color:#77727c; }.type-card.selected svg { color:var(--coral); }
.field-row { display:grid; grid-template-columns:1fr 1fr; gap:12px; }
.email-builder>span { padding-left:10px; border-left:1px solid #eae5e1; color:#7e7982; font-size:11px; white-space:nowrap; }
.field-hint { display:block; margin-top:6px; color:#99939b; font-size:9px; font-weight:500; }
.auth-submit { width:100%; margin-top:3px; }
.auth-submit:disabled { opacity:.65; cursor:wait; transform:none; }
.auth-switch { margin:20px 0 0; text-align:center; color:#85808a; font-size:11px; }.auth-switch a { color:var(--ink); font-weight:700; }
.form-error { margin:0; padding:10px 12px; border:1px solid #ffd3ca; border-radius:9px; background:#fff2ef; color:#b54f3d; font-size:10px; line-height:1.4; }
.auth-success { padding:35px 10px; display:flex; flex-direction:column; align-items:center; text-align:center; }
.auth-success>span { width:50px; height:50px; display:grid; place-items:center; border-radius:16px; background:#dff5ed; color:#359374; }
.auth-success .auth-submit { margin-top:25px; }
.auth-orb { position:absolute; border-radius:50%; filter:blur(2px); opacity:.65; }.auth-orb-one { width:240px; height:240px; top:-80px; right:10%; background:#ffe0d3; }.auth-orb-two { width:180px; height:180px; bottom:-70px; left:7%; background:#d9f1f0; }
.dashboard { min-height:100vh; display:grid; grid-template-columns:270px 1fr; background:#f5f4f2; }
.dashboard-sidebar { height:100vh; padding:29px 20px 19px; position:sticky; top:0; display:flex; flex-direction:column; border-right:1px solid #e6e2de; background:#fffefd; box-shadow:4px 0 28px #0808210c; z-index:12; }
.dashboard-brand-row { padding:0 9px; display:flex; align-items:center; justify-content:space-between; }
.dashboard-brand-row>button { display:none; }
.dashboard-compose { min-height:45px; margin:35px 3px 29px; display:flex; align-items:center; justify-content:center; gap:9px; border:0; border-radius:11px; background:var(--ink); color:white; font-weight:700; box-shadow:0 6px 18px #08082128; transition:transform .18s ease,box-shadow .18s ease,background .18s ease; }
.dashboard-compose:hover { transform:scale(1.02); box-shadow:0 10px 26px #08082136; background:#12102a; }
.dashboard-compose:active { transform:scale(0.99); box-shadow:0 4px 12px #08082122; }
.dashboard-nav-group { margin-bottom:24px; }
.dashboard-nav-group h2 { margin:0 10px 8px; display:flex; align-items:center; gap:8px; color:#97929a; font-size:10px; letter-spacing:1.2px; text-transform:uppercase; }
.dashboard-nav-group nav { display:grid; gap:3px; }
.dashboard-nav-group button { min-height:39px; padding:0 11px; display:flex; align-items:center; gap:11px; border:0; border-radius:9px; background:transparent; color:#6f6b76; font-size:13px; text-align:left; transition:background .15s ease,color .15s ease; }
.dashboard-nav-group button:hover { background:#faf5f2; color:var(--ink); }
.dashboard-nav-group button.active { background:#fff1ed; color:#c95945; font-weight:700; box-shadow:inset 2px 0 0 var(--coral); }
.dashboard-profile { margin-top:auto; padding:13px 5px 0; display:grid; grid-template-columns:34px minmax(0,1fr) 31px; align-items:center; gap:9px; border-top:1px solid var(--line); }
.dashboard-profile>span { width:34px; height:34px; display:grid; place-items:center; border-radius:11px; background:var(--ink); color:white; font-size:10px; font-weight:700; text-transform:uppercase; }
.dashboard-profile>div { min-width:0; display:grid; gap:2px; }
.dashboard-profile strong,.dashboard-profile small { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.dashboard-profile strong { font-size:11px; }.dashboard-profile small { color:#99949d; font-size:8px; }
.dashboard-profile button,.dashboard-brand-row>button,.dashboard-mobile-header>button { width:31px; height:31px; display:grid; place-items:center; border:0; border-radius:9px; background:transparent; color:#8c8790; }
.dashboard-profile button:hover { color:var(--ink); background:#f5f1ee; }
.dashboard-content { min-width:0; padding:28px clamp(28px,4vw,60px); background:#f5f4f2; }
.dashboard-content-heading { display:flex; align-items:end; justify-content:space-between; gap:20px; }
.dashboard-content-heading p { margin:0 0 7px; color:#7a757f; font-size:11px; font-weight:700; letter-spacing:1px; text-transform:uppercase; }
.dashboard-content-heading h1 { margin:0; color:var(--ink); font-size:clamp(35px,4vw,48px); letter-spacing:-2.5px; }
.dashboard-account-pill { padding:8px 12px; border:1px solid #f1dcd5; border-radius:99px; background:#fff5f1; color:#a95343; font-size:10px; font-weight:700; }
.dashboard-empty { min-height:390px; margin-top:35px; display:flex; flex-direction:column; align-items:center; justify-content:center; border:1px solid var(--line); border-radius:22px; background:white; text-align:center; box-shadow:0 18px 50px #392e3e0a; }
.dashboard-empty>span,.company-soon-icon { width:53px; height:53px; display:grid; place-items:center; border-radius:16px; background:#fff0eb; color:var(--coral); }
.dashboard-empty h2 { margin:17px 0 7px; font:600 20px "Manrope"; letter-spacing:-.7px; }
.dashboard-empty p { margin:0; color:#8c8790; font-size:12px; }
.dashboard-mobile-header { display:none; }
.dashboard-loading { min-height:100vh; display:flex; flex-direction:column; align-items:center; justify-content:center; gap:18px; background:#fffdfb; color:#8d8891; font-size:12px; }
.company-soon { min-height:100vh; padding:0 clamp(22px,5vw,64px); display:flex; flex-direction:column; overflow:hidden; background:radial-gradient(circle at 78% 25%,#f3efd2 0,transparent 26%),linear-gradient(135deg,#fffdfb,#fff4ef); }
.company-soon header { height:88px; display:flex; align-items:center; justify-content:space-between; }
.company-soon header button { display:flex; align-items:center; gap:7px; border:0; background:transparent; color:#77727c; font-size:12px; }
.company-soon section { margin:auto; padding:40px 0 120px; display:flex; flex-direction:column; align-items:center; text-align:center; }
.company-soon section>.eyebrow { margin-top:22px; }
.company-soon h1 { margin:20px 0; font-size:clamp(45px,7vw,78px); }
.company-soon section>p:last-child { margin:0; color:#77727c; font-size:14px; }
.company-dns-setup { min-height:100vh; padding:0 clamp(22px,5vw,64px) 80px; display:flex; flex-direction:column; background:radial-gradient(circle at 78% 25%,#f3efd2 0,transparent 26%),linear-gradient(135deg,#fffdfb,#fff4ef); }
.company-dns-header { height:88px; display:flex; align-items:center; justify-content:space-between; }
.company-dns-header button { border:0; background:transparent; color:#77727c; font-size:12px; }
.company-dns-card { width:min(920px,100%); margin:0 auto; padding:34px 0; display:flex; flex-direction:column; gap:18px; }
.company-dns-card h1 { margin:0; font-size:clamp(34px,5vw,52px); letter-spacing:-1.2px; }
.company-dns-card h1 em { font-style:normal; color:var(--coral); }
.company-dns-lead { margin:0; color:#77727c; font-size:14px; line-height:1.6; max-width:720px; }
.dns-notes { margin:4px 0 0; padding-left:18px; color:#7d7882; font-size:12px; line-height:1.55; }
.dns-notes li+li { margin-top:6px; }
.company-dns-checks { display:grid; gap:8px; }
.company-dns-check { padding:10px 12px; display:flex; align-items:flex-start; gap:10px; border-radius:10px; font-size:12px; }
.company-dns-check strong { display:block; font-size:10px; letter-spacing:.4px; }
.company-dns-check span { color:#7d7882; }
.company-dns-check.ok { background:#eef8f1; color:#2f6b43; }
.company-dns-check.fail { background:#fff0eb; color:#b04a38; }
.company-dns-next { align-self:flex-start; margin-top:8px; display:inline-flex; align-items:center; gap:8px; }
.dns-copy { flex-shrink:0; border:0; border-radius:7px; padding:4px; background:#f7f3f1; color:#8c8790; cursor:pointer; }
.dns-copy:hover { background:#fff0eb; color:var(--coral); }
.dashboard-scrim { display:none; }
.dashboard-notice { padding:12px 16px; position:fixed; right:24px; bottom:24px; z-index:30; border-radius:11px; background:var(--ink); color:white; box-shadow:0 14px 35px #08082138; font-size:12px; font-weight:600; }
.mailbox-workspace { min-height:calc(100vh - 56px); display:grid; grid-template-columns:minmax(0,70fr) minmax(0,30fr); align-items:stretch; gap:22px; }
.mailbox-main-column { min-width:0; display:flex; flex-direction:column; }
.mailbox-shell { min-height:calc(95vh - 158px); margin-top:24px; overflow:hidden; border:1px solid #e6e2de; border-radius:18px; background:white; box-shadow:0 1px 2px #08082106,0 12px 40px #0808210a; }
.mailbox-list-pane { min-width:0; min-height:calc(95vh - 158px); position:relative; display:flex; flex-direction:column; }
.mailbox-notes-pane { min-width:0; min-height:calc(100vh - 56px); max-height:calc(100vh - 56px); overflow:hidden; display:flex; flex-direction:column; border:1px solid #e6e2de; border-radius:18px; background:#fffefd; box-shadow:0 1px 2px #08082106,0 14px 42px #08082110; }
.mailbox-toolbar { min-height:62px; padding:11px 15px; display:flex; align-items:center; gap:8px; border-bottom:1px solid #ebe7e3; background:#fffefd; }
.mailbox-toolbar>label { height:38px; min-width:160px; flex:1; padding:0 12px; display:flex; align-items:center; gap:8px; border:1px solid #e8e3df; border-radius:10px; color:#aaa4ac; background:#fcfbfa; }
.mailbox-toolbar input { min-width:0; width:100%; border:0; outline:0; background:transparent; color:var(--ink); font-size:12px; }
.mailbox-toolbar>button,.mail-detail-toolbar button { height:36px; padding:0 10px; display:flex; align-items:center; justify-content:center; gap:6px; border:0; border-radius:9px; background:transparent; color:#77717a; font-size:10px; font-weight:700; }
.mailbox-toolbar>button:hover,.mailbox-toolbar>button.active,.mail-detail-toolbar button:hover { background:#fff0eb; color:#bd5947; }
.mailbox-filters { padding:13px 15px; display:grid; grid-template-columns:1fr 1.5fr 1fr 1fr; gap:10px; border-bottom:1px solid var(--line); background:#fcfaf9; }
.mailbox-filters label { display:grid; gap:5px; color:#76717a; font-size:9px; font-weight:700; text-transform:uppercase; letter-spacing:.5px; }
.mailbox-filters input,.mailbox-filters select { min-width:0; height:34px; padding:0 9px; border:1px solid #e4dfdc; border-radius:8px; outline:0; background:white; color:#393745; font-size:10px; text-transform:none; }
.mailbox-filters input:focus,.mailbox-filters select:focus { border-color:#f0ad9f; box-shadow:0 0 0 2px #ff806c12; }
.mailbox-rows { min-height:120px; flex:0 0 auto; }
.mailbox-row { width:100%; min-height:62px; padding:10px 15px 10px 13px; display:grid; grid-template-columns:20px 36px minmax(85px,140px) minmax(0,1fr) auto 28px; align-items:center; gap:10px 12px; border:0; border-bottom:1px solid #ebe7e3; border-left:2px solid transparent; background:white; color:#4a4652; text-align:left; cursor:pointer; transition:background .15s ease,border-color .15s ease; }
.mailbox-row:focus-visible { outline:2px solid #f0ad9f; outline-offset:-2px; }
.sender-avatar { width:36px; height:36px; border-radius:50%; display:grid; place-items:center; color:white; font-size:11px; font-weight:700; letter-spacing:.02em; flex-shrink:0; user-select:none; line-height:1; }
.sender-avatar img { width:100%; height:100%; border-radius:50%; object-fit:cover; display:block; }
.mailbox-avatar { align-self:center; }
.mailbox-row:hover { background:#faf8f6; border-left-color:var(--coral); }
.mailbox-row.selected { background:#fff8f5; border-left-color:var(--coral); }
.mailbox-read-dot { display:grid; place-items:center; color:#9a959f; }
.mailbox-read-dot i { width:7px; height:7px; display:block; border-radius:50%; background:var(--coral); box-shadow:0 0 0 3px #fff0eb; }
.mailbox-sender,.mailbox-summary strong,.mailbox-summary span { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.mailbox-sender { font-size:12px; font-weight:500; color:#3a3642; }
.mailbox-summary { min-width:0; display:flex; gap:6px; align-items:baseline; font-size:12px; }
.mailbox-summary strong { min-width:0; max-width:100%; flex:0 1 auto; font-weight:500; color:#2a2733; }
.mailbox-summary span { min-width:0; flex:1 1 0; color:#7a7580; font-weight:400; }
.mailbox-summary b { color:#9a959f; font-weight:400; }
.mailbox-row.unread .mailbox-sender,.mailbox-row.unread .mailbox-summary strong { color:var(--ink); font-weight:700; }
.mailbox-row.unread .mailbox-summary span { color:#5c5763; font-weight:400; }
.mailbox-row time { color:#8f8a94; font-size:10px; white-space:nowrap; }
.mailbox-star { width:28px; height:28px; padding:0; display:grid; place-items:center; border:0; border-radius:8px; background:transparent; color:#c5c0c8; cursor:pointer; transition:color .15s ease,background .15s ease; }
.mailbox-star:hover,.mailbox-star.active { color:#e8a317; background:#fff8e8; }
.mailbox-row.starred .mailbox-star { color:#e8a317; }
.mailbox-list-footer { margin-top:auto; padding:18px 20px 20px; display:flex; align-items:center; justify-content:space-between; gap:16px; border-top:1px solid #ebe7e3; background:linear-gradient(180deg,#fcfbfa 0%,#f8f7f5 100%); color:#8f8a94; font-size:11px; }
.mailbox-list-footer p { margin:0; color:#5c5763; font-weight:600; }
.mailbox-list-footer span { color:#a09aa4; font-size:10px; }
.mailbox-state { min-height:360px; padding:30px; display:flex; flex-direction:column; align-items:center; justify-content:center; color:#8d8891; text-align:center; font-size:11px; }
.mailbox-state.empty span { width:46px; height:46px; margin-bottom:13px; display:grid; place-items:center; border-radius:14px; color:var(--coral); background:#fff0eb; }
.mailbox-state.empty strong { color:#393541; font:600 15px "Manrope"; }
.mailbox-state p { margin:7px 0; }
.mailbox-state.error { color:#a94f40; }
.mailbox-state button { margin-top:8px; padding:7px 12px; border:0; border-radius:8px; background:var(--ink); color:white; font-size:10px; }
.mailbox-pagination { min-height:52px; padding:8px 14px; display:flex; align-items:center; justify-content:space-between; border-top:1px solid var(--line); color:#99949d; font-size:9px; }
.mailbox-pagination button { height:32px; padding:0 9px; display:flex; align-items:center; gap:4px; border:1px solid #e6e1dd; border-radius:8px; background:white; color:#5f5a64; font-size:9px; font-weight:700; }
.mailbox-pagination button:disabled { opacity:.4; cursor:not-allowed; }
.sticky-notes-header { min-height:64px; padding:11px 18px; display:flex; align-items:center; justify-content:space-between; border-bottom:1px solid #ebe7e3; background:#fffefd; }
.sticky-notes-title { display:flex; align-items:center; gap:8px; color:#5c5763; }
.sticky-notes-title>svg { transform:rotate(-24deg); transform-origin:center; }
.sticky-notes-title h2 { margin:0; font:700 11px "DM Sans"; letter-spacing:.9px; text-transform:uppercase; color:#4a4652; }
.sticky-notes-actions button { width:34px; height:34px; padding:0; display:grid; place-items:center; border:0; border-radius:9px; background:transparent; color:#77717a; cursor:pointer; }
.sticky-notes-actions button:hover { background:#fff0eb; color:#bd5947; }
.sticky-notes-body { flex:1; padding:20px 16px 18px; display:flex; flex-direction:column; gap:13px; overflow:auto; }
.sticky-notes-state { padding:24px 12px; color:#8d8891; font-size:11px; text-align:center; }
.sticky-notes-state.error { color:#a94f40; }
.sticky-note { height:132px; min-height:132px; position:relative; isolation:isolate; padding:19px 16px 14px; display:flex; flex-direction:column; border:1px solid #ffffffa8; border-radius:5px 7px 8px 6px; transform:rotate(-.45deg); box-shadow:0 2px 3px #31282120,0 8px 15px #31282116,0 18px 34px #3128210b; transition:transform .16s ease,box-shadow .16s ease; }
.sticky-note:nth-of-type(even) { transform:rotate(.5deg); }
.sticky-note:hover { transform:rotate(0) translateY(-2px); box-shadow:0 3px 4px #31282124,0 11px 20px #31282119,0 22px 38px #3128210e; }
.sticky-note::before { content:""; position:absolute; inset:0; z-index:-1; border-radius:inherit; background:linear-gradient(115deg,#ffffff38 0%,transparent 28%,transparent 72%,#00000008 100%); pointer-events:none; }
.sticky-note::after { content:""; position:absolute; z-index:-2; right:7px; bottom:-5px; width:45%; height:12px; border-radius:0 0 9px 45%; background:#443a3224; filter:blur(4px); transform:skewX(-16deg) rotate(-1deg); transform-origin:right; }
.sticky-note-pin { position:absolute; z-index:2; top:-13px; left:50%; width:34px; height:31px; overflow:hidden; transform:translateX(-50%) rotate(-7deg); filter:drop-shadow(1px 3px 2px #3028204a); pointer-events:none; }
.sticky-note-pin img { width:34px; height:34px; display:block; object-fit:cover; object-position:center top; clip-path:inset(0 0 10% 0); }
.sticky-note-header { flex:0 0 auto; display:flex; align-items:center; justify-content:space-between; gap:8px; margin-bottom:9px; }
.sticky-note-header h3 { margin:0; font:700 14px "Manrope"; color:#252230; letter-spacing:-.25px; line-height:1.25; }
.sticky-note-menu-wrap { position:relative; flex-shrink:0; }
.sticky-note-menu-btn { width:26px; height:26px; padding:0; display:grid; place-items:center; border:0; border-radius:7px; background:transparent; color:#8f8a94; cursor:pointer; }
.sticky-note-menu-btn:hover { background:#ffffff80; color:#5c5763; }
.sticky-note-menu { position:absolute; top:100%; right:0; z-index:2; min-width:120px; padding:4px; border:1px solid #e6e2de; border-radius:9px; background:white; box-shadow:0 8px 24px #08082118; }
.sticky-note-menu button { width:100%; padding:8px 10px; display:flex; align-items:center; gap:7px; border:0; border-radius:7px; background:transparent; color:#a94f40; font-size:11px; font-weight:600; cursor:pointer; text-align:left; }
.sticky-note-menu button:hover { background:#fff5f3; }
.sticky-note p { min-height:0; margin:0 0 10px; padding-right:4px; flex:1 1 auto; overflow:auto; color:#6b6872; font-size:11px; font-weight:400; line-height:1.55; white-space:pre-wrap; overflow-wrap:anywhere; scrollbar-width:thin; scrollbar-color:#8f8a9442 transparent; }
.sticky-note p::-webkit-scrollbar { width:4px; }
.sticky-note p::-webkit-scrollbar-thumb { border-radius:999px; background:#8f8a9442; }
.sticky-note-footer { margin-top:auto; flex:0 0 auto; display:flex; align-items:end; justify-content:space-between; gap:10px; }
.sticky-note-tags { display:flex; flex-wrap:wrap; gap:5px; }
.sticky-note-tags span { padding:3px 8px; border-radius:999px; font-size:9px; font-weight:700; letter-spacing:.2px; }
.sticky-note-footer time { color:#8f8a94; font-size:9px; white-space:nowrap; }
.sticky-note-yellow { background:linear-gradient(145deg,#fff9df,#fff4c9); }
.sticky-note-yellow .sticky-note-tags span { background:#fff3c4; color:#b8860b; }
.sticky-note-green { background:linear-gradient(145deg,#f0fae9,#e2f3d7); }
.sticky-note-green .sticky-note-tags span { background:#d8f0dc; color:#2d7a38; }
.sticky-note-blue { background:linear-gradient(145deg,#edf7ff,#ddebfa); }
.sticky-note-blue .sticky-note-tags span { background:#d5e8ff; color:#2a6db0; }
.sticky-note-pink { background:linear-gradient(145deg,#fff1f4,#f9dfe5); }
.sticky-note-pink .sticky-note-tags span { background:#fce0ea; color:#c44d75; }
.sticky-note-add { width:100%; min-height:68px; padding:10px 14px; display:grid; grid-template-columns:24px auto; grid-template-rows:auto auto; column-gap:8px; align-content:center; justify-content:center; align-items:center; border:1.5px dashed #d1cbc5; border-radius:10px; background:#fffcfa80; color:#8f8a94; cursor:pointer; transition:border-color .15s ease,background .15s ease; }
.sticky-note-add>svg { grid-row:1/3; }
.sticky-note-add:hover { border-color:#c5bdb4; background:white; color:#5c5763; }
.sticky-note-add span { align-self:end; font-size:11px; font-weight:700; color:#5c5763; text-align:left; }
.sticky-note-add small { align-self:start; font-size:9px; color:#aaa4ac; text-align:left; }
.sticky-note-form { padding:14px; display:grid; gap:10px; border:1px solid #e6e2de; border-radius:12px; background:white; box-shadow:0 4px 16px #0808210c; }
.sticky-note-form input,.sticky-note-form textarea { width:100%; padding:9px 10px; border:1px solid #e4dfdc; border-radius:8px; outline:0; background:#fcfbfa; color:#393745; font:500 11px "DM Sans"; resize:vertical; }
.sticky-note-form input:focus,.sticky-note-form textarea:focus { border-color:#f0ad9f; box-shadow:0 0 0 2px #ff806c12; }
.sticky-note-colors { display:flex; gap:8px; }
.sticky-note-color { width:22px; height:22px; padding:0; border:2px solid transparent; border-radius:50%; cursor:pointer; }
.sticky-note-color[data-color="yellow"] { background:#fff3c4; }
.sticky-note-color[data-color="green"] { background:#d8f0dc; }
.sticky-note-color[data-color="blue"] { background:#d5e8ff; }
.sticky-note-color[data-color="pink"] { background:#fce0ea; }
.sticky-note-color.active { border-color:#393745; box-shadow:0 0 0 2px white; }
.sticky-note-form-actions { display:flex; justify-content:flex-end; gap:8px; }
.sticky-note-form-actions button { height:32px; padding:0 12px; border:0; border-radius:8px; font-size:10px; font-weight:700; cursor:pointer; }
.sticky-note-form-actions button[type="button"] { background:#f3f0ed; color:#5c5763; }
.sticky-note-form-actions button[type="submit"] { background:var(--ink); color:white; }
.sticky-note-form-actions button:disabled { opacity:.5; cursor:not-allowed; }
.mail-detail { min-width:0; display:none; position:absolute; inset:0; z-index:4; overflow:auto; border-left:0; background:#fffefd; }
.mail-detail.open { display:block; }
.mail-detail-toolbar { min-height:62px; padding:10px 13px; display:flex; align-items:center; justify-content:space-between; border-bottom:1px solid var(--line); }
.mail-detail-toolbar>div { display:flex; gap:3px; }
.mail-detail-toolbar button { width:36px; padding:0; }
.mail-detail-toolbar button.danger { color:#bb4e43; }
.mail-detail-close { display:none!important; }
.mail-detail-state { min-height:320px; padding:30px; display:grid; place-items:center; color:#8d8891; font-size:11px; text-align:center; }
.mail-detail-state.error { color:#a94f40; }
.mail-detail-content { padding:26px clamp(19px,3vw,34px) 40px; }
.mail-detail-content h2 { margin:0 0 22px; overflow-wrap:anywhere; font:600 21px/1.3 "Manrope"; letter-spacing:-.7px; }
.mail-detail-meta { padding-bottom:19px; display:flex; align-items:start; justify-content:space-between; gap:14px; border-bottom:1px solid var(--line); }
.mail-detail-meta>div { min-width:0; display:grid; gap:3px; }
.mail-detail-sender { display:flex; align-items:center; gap:10px; min-width:0; }
.mail-detail-sender .sender-avatar { width:40px; height:40px; font-size:12px; flex-shrink:0; }
.mail-detail-sender strong { overflow:hidden; text-overflow:ellipsis; font-size:11px; }
.mail-detail-meta strong { overflow:hidden; text-overflow:ellipsis; font-size:11px; }
.mail-detail-meta small,.mail-detail-meta time { color:#928d96; font-size:8px; line-height:1.5; }
.mail-detail-meta time { white-space:nowrap; }
.mail-detail-body { padding-top:23px; color:#383440; font-size:12px; line-height:1.75; white-space:pre-wrap; overflow-wrap:anywhere; }
.compose-overlay { position:fixed; inset:0; z-index:40; padding:24px; display:flex; align-items:center; justify-content:center; background:#08082175; backdrop-filter:blur(5px); }
.compose-modal { width:min(760px,100%); max-height:calc(100vh - 48px); overflow:auto; border:1px solid #eee7e2; border-radius:22px; background:#fffefd; box-shadow:0 32px 90px #08082150; }
.compose-header { min-height:76px; padding:0 24px; display:flex; align-items:center; justify-content:space-between; border-bottom:1px solid var(--line); background:linear-gradient(100deg,#fff6f2,#fffefd); }
.compose-header>div { display:flex; align-items:center; gap:11px; }
.compose-header>div>span { width:34px; height:34px; display:grid; place-items:center; border-radius:11px; background:var(--ink); color:var(--coral); }
.compose-header h2 { margin:0; font:600 17px "Manrope"; letter-spacing:-.5px; }
.compose-header small { display:block; margin-top:2px; color:#97919a; font-size:9px; }
.compose-header>button { width:35px; height:35px; display:grid; place-items:center; border:0; border-radius:10px; background:transparent; color:#77717a; }
.compose-header>button:hover { background:#f4eeea; color:var(--ink); }
.compose-form { padding:0 24px 22px; }
.compose-recipient-row { min-height:55px; display:grid; grid-template-columns:28px 1fr auto; align-items:center; gap:9px; border-bottom:1px solid var(--line); }
.compose-recipient-row label,.compose-copy-fields label { color:#77717a; font-size:11px; font-weight:700; }
.compose-recipient-row input,.compose-copy-fields input,.compose-subject,.compose-ai input { min-width:0; border:0; outline:0; background:transparent; color:var(--ink); font:500 12px "DM Sans"; }
.compose-recipient-row>button { padding:6px 8px; border:0; border-radius:7px; background:transparent; color:#847e87; font-size:10px; font-weight:700; }
.compose-recipient-row>button:hover { background:var(--blush); color:#bc5948; }
.compose-copy-fields { display:grid; grid-template-columns:1fr 1fr; border-bottom:1px solid var(--line); }
.compose-copy-fields label { min-height:48px; padding-right:18px; display:grid; grid-template-columns:32px 1fr; align-items:center; }
.compose-copy-fields label+label { padding-left:18px; border-left:1px solid var(--line); }
.compose-subject { width:100%; height:52px; border-bottom:1px solid var(--line); font-size:13px; }
.compose-format-row { padding:12px 0; display:flex; align-items:center; justify-content:space-between; }
.compose-format-row>span { color:#97919a; font-size:9px; font-weight:700; letter-spacing:.8px; text-transform:uppercase; }
.compose-format-row>div { padding:3px; display:flex; border-radius:9px; background:#f3efec; }
.compose-format-row button { min-width:56px; height:27px; border:0; border-radius:7px; background:transparent; color:#85808a; font-size:9px; font-weight:700; }
.compose-format-row button.active { background:white; color:var(--ink); box-shadow:0 3px 9px #241a2b18; }
.compose-body { width:100%; min-height:210px; padding:17px; resize:vertical; border:1px solid #ebe5e1; outline:0; border-radius:13px; background:white; color:var(--ink); font:400 13px/1.65 "DM Sans"; }
.compose-body:focus { border-color:#f0ad9f; box-shadow:0 0 0 3px #ff806c14; }
.compose-tools { padding:9px 3px; display:flex; gap:3px; border-bottom:1px solid var(--line); }
.compose-tools button { width:32px; height:30px; display:grid; place-items:center; border:0; border-radius:8px; background:transparent; color:#77717a; }
.compose-tools button:hover { background:#fff0eb; color:#c25c49; }
.compose-tools button:disabled { opacity:.3; cursor:not-allowed; }
.compose-tools button:disabled:hover { background:transparent; color:#77717a; }
.compose-tools>span { margin-left:7px; align-self:center; color:#aaa4ac; font-size:8px; }
.compose-ai { min-height:58px; margin:14px 0 10px; padding:8px 9px 8px 14px; display:grid; grid-template-columns:auto 1fr auto; align-items:center; gap:10px; border:1px solid #e9e4ef; border-radius:14px; background:linear-gradient(100deg,#f5f4fb,#fff6f2); }
.compose-ai>svg { color:#7463a8; }
.compose-ai button { min-height:35px; padding:0 13px; border:0; border-radius:9px; background:var(--ink); color:white; font-size:10px; font-weight:700; }
.compose-ai button:disabled { opacity:.45; cursor:not-allowed; }
.compose-error { margin:8px 0; color:#b54f3d; font-size:10px; }
.compose-footer { display:flex; align-items:center; justify-content:space-between; gap:16px; }
.compose-footer>span { color:#99939b; font-size:9px; }
.compose-send { min-width:105px; min-height:41px; display:flex; align-items:center; justify-content:center; gap:8px; border:0; border-radius:10px; background:var(--coral); color:var(--ink); box-shadow:0 9px 20px #ff806c3b; font-weight:700; }
.compose-send:hover { transform:translateY(-1px); box-shadow:0 13px 25px #ff806c57; }
.compose-send:disabled { opacity:.6; cursor:wait; transform:none; }
@media(max-width:900px) {
.web-view { display:none; }.mobile-view { display:block; }
.mobile-landing { min-height:100vh; overflow:hidden; padding-bottom:28px; background:radial-gradient(circle at 90% 36%,#f3efd0,transparent 27%),#fffdfb; }
.mobile-nav { height:72px; padding:0 22px; display:flex; align-items:center; justify-content:space-between; }
.icon-button { width:39px; height:39px; display:grid; place-items:center; border:1px solid #e8e3df; border-radius:11px; background:white; }
.mobile-hero { padding:42px 24px 27px; display:flex; flex-direction:column; align-items:center; text-align:center; }
.mobile-hero h1 { margin:21px 0 16px; font-size:clamp(43px,13vw,61px); letter-spacing:-3.5px; }
.mobile-hero>p { max-width:420px; margin:0 0 26px; color:var(--muted); font-size:15px; line-height:1.6; }
.mobile-hero .button { width:min(100%,330px); }
.mobile-login { margin-top:15px; border:0; background:none; color:#7e7a84; font-size:12px; }.mobile-login strong { color:var(--ink); }
.phone-preview { width:min(86%,390px); margin:34px auto 0; padding:11px 16px 14px; position:relative; border:5px solid white; border-radius:31px; background:#fffefd; box-shadow:0 28px 70px #32283733; transform:rotate(-1deg); }
.phone-preview:before { content:""; position:absolute; z-index:-1; inset:-22px -45px 20px; border-radius:42% 45% 40% 44%; background:linear-gradient(145deg,#ffe0cf,#f6efc9 55%,#cfedf0); }
.phone-top { height:30px; padding-inline:6px; display:flex; align-items:center; justify-content:space-between; font-size:8px; font-weight:700; }
.phone-pill { width:61px; height:18px; border-radius:99px; background:var(--ink); }
.phone-preview>header { padding:9px 6px; display:flex; align-items:center; justify-content:space-between; }.phone-preview>header div { display:grid; }.phone-preview>header small { color:#99949c; font-size:8px; }.phone-preview>header strong { font-size:13px; }.phone-preview>header .avatar { margin:0; }
.mobile-summary { margin:10px 3px 18px; padding:11px; display:grid; grid-template-columns:35px 1fr auto; align-items:center; gap:10px; border:1px solid #ffded5; border-radius:13px; background:#fff1ed; }
.mobile-summary div { display:grid; }.mobile-summary strong { font-size:10px; }.mobile-summary small { color:#8d8183; font-size:8px; }
.mobile-inbox-title { padding-inline:4px; display:flex; align-items:center; justify-content:space-between; }.mobile-inbox-title h3 { margin:0; font:600 16px "Manrope"; }.mobile-inbox-title span { color:var(--coral); font-size:8px; }
.mobile-mail { padding:12px 5px; }.mobile-mail strong { display:block; font-size:9px; }.mobile-mail p { margin:3px 0 0; color:#85818a; font-size:8px; }.mobile-mail time { color:#aaa5aa; font-size:7px; }
.mobile-tabbar { height:49px; margin-top:10px; display:flex; align-items:center; justify-content:space-around; border-radius:14px; background:var(--ink); color:#9291a4; }.mobile-tabbar .active { color:var(--coral); }.mobile-tabbar button { width:37px; height:37px; display:grid; place-items:center; border:0; border-radius:12px; background:var(--coral); font-size:23px; }
.mobile-benefits { margin:38px auto 0; display:flex; justify-content:center; gap:18px; color:#77727d; font-size:10px; }
.auth-nav { width:calc(100% - 40px); height:72px; }
.auth-shell { width:min(100% - 32px,560px); min-height:auto; padding:28px 0 50px; display:block; }
.auth-story { margin-bottom:28px; text-align:center; display:flex; flex-direction:column; align-items:center; }
.auth-story h1 { margin:18px 0 13px; font-size:clamp(40px,11vw,54px); }
.auth-story>p { margin:0; font-size:14px; }
.auth-points { display:none; }
.auth-card { padding:25px 21px; border-radius:20px; }
.back-link { font-size:0; }.back-link svg { width:20px; height:20px; }
.dashboard { display:block; }
.dashboard-mobile-header { height:70px; padding:0 20px; display:flex; align-items:center; justify-content:space-between; border-bottom:1px solid var(--line); background:#fffefd; }
.dashboard-sidebar { width:min(290px,85vw); position:fixed; left:0; transform:translateX(-105%); transition:transform .25s ease; box-shadow:18px 0 45px #18111f24; }
.dashboard-sidebar.open { transform:translateX(0); }
.dashboard-brand-row>button { display:grid; }
.dashboard-scrim { display:block; position:fixed; inset:0; z-index:11; border:0; background:#08082155; }
.dashboard-content { padding:32px 20px; }
.dashboard-empty { min-height:340px; }
.mailbox-workspace { min-height:auto; display:block; }
.mailbox-shell { display:block; }
.mailbox-notes-pane { display:none; }
.mailbox-list-pane { border-right:0; }
.mailbox-shell.detail-visible .mailbox-toolbar,
.mailbox-shell.detail-visible .mailbox-filters,
.mailbox-shell.detail-visible .mailbox-rows,
.mailbox-shell.detail-visible .mailbox-list-footer,
.mailbox-shell.detail-visible .mailbox-pagination { display:none; }
.mailbox-shell.detail-visible .mailbox-list-pane { display:flex; min-height:520px; }
.mail-detail { position:static; flex:1; }
.mail-detail-close { display:flex!important; }
.compose-overlay { padding:12px; align-items:flex-end; }
.compose-modal { max-height:calc(100vh - 24px); border-radius:19px 19px 12px 12px; }
}
@media(max-width:480px) {
.account-types,.field-row { grid-template-columns:1fr; }.auth-story h1 br { display:none; }
.compose-overlay { padding:0; }
.compose-modal { width:100%; max-height:100vh; border:0; border-radius:0; }
.compose-header { min-height:64px; padding:0 16px; }
.compose-form { padding:0 15px 16px; }
.compose-copy-fields { grid-template-columns:1fr; }
.compose-copy-fields label+label { padding-left:0; border-left:0; border-top:1px solid var(--line); }
.compose-body { min-height:32vh; }
.compose-ai { grid-template-columns:auto 1fr; }
.compose-ai button { grid-column:1/-1; }
.compose-footer>span { display:none; }
.compose-send { width:100%; }
.dashboard-content-heading { align-items:center; }
.dashboard-content-heading h1 { font-size:34px; }
.dashboard-account-pill { display:none; }
.mailbox-shell { margin-top:20px; border-radius:14px; }
.mailbox-toolbar { padding:9px; }
.mailbox-toolbar>button span { display:none; }
.mailbox-filters { grid-template-columns:1fr 1fr; }
.mailbox-row { min-height:70px; padding:10px 11px; grid-template-columns:17px 32px minmax(0,1fr) auto 26px; gap:8px 9px; }
.sender-avatar.mailbox-avatar { width:32px; height:32px; font-size:10px; }
.mailbox-sender { grid-column:3; align-self:end; }
.mailbox-summary { grid-column:3/5; display:flex; align-self:start; }
.mailbox-summary strong { max-width:100%; }
.mailbox-row time { grid-column:4; grid-row:1; align-self:end; }
.mailbox-star { grid-column:5; grid-row:1; align-self:center; width:26px; height:26px; }
.mailbox-read-dot { grid-row:1/3; }
.mailbox-avatar { grid-row:1/3; align-self:center; }
}
@media(max-width:380px) { .mobile-hero { padding-top:25px; }.mobile-hero h1 { font-size:41px; }.phone-preview { width:90%; } }
.dns-results { margin-top:22px; display:grid; gap:18px; }
.dns-summary { margin:0; color:#4f4f62; font-size:12px; line-height:1.5; }
.dns-notes { margin:0; padding-left:18px; color:#74717c; font-size:11px; line-height:1.55; }
.dns-section h3 { margin:0 0 10px; font:600 15px "Manrope"; letter-spacing:-.3px; }
.dns-table-wrap { overflow:auto; border:1px solid var(--line); border-radius:12px; background:#fff; }
.dns-table { width:100%; border-collapse:collapse; font-size:11px; }
.dns-table th,.dns-table td { padding:10px 12px; border-bottom:1px solid #f2eeeb; text-align:left; vertical-align:top; }
.dns-table th { color:#74717c; font-size:10px; font-weight:600; text-transform:uppercase; letter-spacing:.4px; background:#fcf9f7; }
.dns-table tr:last-child td { border-bottom:0; }
.dns-table code { font:500 11px ui-monospace,SFMono-Regular,Menlo,monospace; word-break:break-all; }
.dns-value-cell { display:flex; align-items:flex-start; gap:8px; }
.dns-copy { width:28px; height:28px; flex:0 0 auto; display:grid; place-items:center; border:1px solid #ece7e3; border-radius:8px; background:white; color:#67677a; }
.link-button { border:0; background:transparent; color:var(--coral); font:inherit; font-weight:600; cursor:pointer; padding:0; }
@media(prefers-reduced-motion:reduce) { * { scroll-behavior:auto!important; transition:none!important; } }

View file

@ -0,0 +1,63 @@
import colorMap from "../data/az_2_letter_color_map.json";
const FALLBACK_COLOR = "#5c5763";
export function addressText(sender) {
if (typeof sender === "string") return sender;
return sender?.email || sender?.address || "";
}
export function senderDisplayName(email) {
const displayName =
email.sender_display_name ||
email.sender_name ||
email.from_name ||
(typeof email.sender === "object" ? email.sender?.name : "");
const address = addressText(email.sender || email.from);
return displayName?.trim() || address.split("@")[0] || "Unknown sender";
}
/** Two-letter key for color lookup: first + last word, or first two chars. */
export function initialsFromName(name) {
const cleaned = String(name || "")
.trim()
.replace(/[^a-zA-Z\s'-]/g, " ");
const parts = cleaned.split(/\s+/).filter(Boolean);
if (parts.length >= 2) {
const first = parts[0][0] || "";
const last = parts[parts.length - 1][0] || "";
return `${first}${last}`.toUpperCase();
}
const word = parts[0] || "?";
const letters = word.replace(/[^a-zA-Z]/g, "");
if (letters.length >= 2) {
return letters.slice(0, 2).toUpperCase();
}
if (letters.length === 1) {
return `${letters[0]}${letters[0]}`.toUpperCase();
}
return "??";
}
export function colorForInitials(initials) {
const key = String(initials || "").toUpperCase().slice(0, 2);
return colorMap[key] || FALLBACK_COLOR;
}
/** Registered-user avatar URL hook — wire when profile photos exist. */
export function senderAvatarUrl(email) {
return email.sender_avatar_url || email.sender?.avatar_url || null;
}
export function senderAvatar(email) {
const name = senderDisplayName(email);
const initials = initialsFromName(name);
return {
name,
initials,
color: colorForInitials(initials),
imageUrl: senderAvatarUrl(email),
};
}