Compare commits

..

10 Commits

Author SHA1 Message Date
KM Koushik
b342335502 add coderabbit as sponsor (#256) 2025-09-26 07:37:05 +10:00
Ashin T V
42377b5041 ui-fix ReputationMetrics (#249) 2025-09-21 17:48:00 +10:00
Alen Abraham
987c07db35 fixed docker setup for psql (#248) 2025-09-21 08:09:11 +10:00
Ashin T V
c6405b47d2 ui-fix topbar (#245) 2025-09-21 06:21:38 +10:00
Ashin T V
cb79be68c7 ui-fix emailchart (#244) 2025-09-21 06:19:06 +10:00
KM Koushik
5780177d26 fix: send founder email on first paid subscription (#247) 2025-09-21 06:18:16 +10:00
KM Koushik
1226e89aaf add postmortem (#241) 2025-09-19 22:09:54 +10:00
KM Koushik
2fa8c1b600 fix build 2025-09-19 08:24:08 +10:00
KM Koushik
81faba2aba add admin mail analytics (#240) 2025-09-19 08:18:23 +10:00
KM Koushik
62a15ef811 add waitlist confirmation (#239) 2025-09-19 07:26:38 +10:00
26 changed files with 863 additions and 1014 deletions

View File

@@ -41,4 +41,4 @@
- Prefer Conventional Commits (`feat:`, `fix:`, `chore:`, `docs:`). Git history shows frequent feat/fix usage.
- PRs must include: clear description, linked issues, screenshots for UI changes, migration notes, and verification steps.
- CI hygiene: ensure `pnpm lint` and `pnpm build` pass; run relevant `db:*` scripts if schema changes.
- never run build,migration commands unless asked for

View File

@@ -118,9 +118,13 @@ Railway provides the quickest way to spin up useSend. Read the [Railway self-hos
We are grateful for the support of our sponsors.
### Our Sponsors
<a href="https://coderabbit.ai/?utm_source=useSend.com" target="_blank">
<img src="https://usesend.com/coderabbit-wordmark.png" alt="coderabbit.ai" style="width:200px;height:100px;">
</a>
<a href="https://doras.to/" target="_blank">
### Other Sponsors
<a href="https://doras.to/?utm_source=useSend.com" target="_blank">
<img src="https://cdn.doras.to/doras/assets/05c5db48-cfba-49d7-82a1-5b4a3751aa40/49ca4647-65ed-412e-95c6-c475633d62af.png" alt="doras.to" style="width:60px;height:60px;">
</a>

View File

@@ -1,3 +1,5 @@
import createMDX from "@next/mdx";
/** @type {import("next").NextConfig} */
const config = {
// Use static export in production by default; keep dev server dynamic
@@ -6,6 +8,11 @@ const config = {
// Required for static export if using images
unoptimized: true,
},
pageExtensions: ["js", "jsx", "md", "mdx", "ts", "tsx"],
};
export default config;
const withMDX = createMDX({
extension: /\.(md|mdx)$/,
});
export default withMDX(config);

View File

@@ -10,6 +10,10 @@
"lint": "eslint . --max-warnings 0"
},
"dependencies": {
"@mdx-js/loader": "^3.1.1",
"@mdx-js/react": "^3.1.1",
"@next/mdx": "^15.5.3",
"@types/mdx": "^2.0.13",
"@usesend/email-editor": "workspace:*",
"@usesend/ui": "workspace:*",
"iconoir-react": "^7.11.0",

View File

@@ -0,0 +1,21 @@
<svg width="2152" height="313" viewBox="0 0 2152 313" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_97_43)">
<path d="M470.612 107.187C479.309 91.1833 491.434 78.6662 506.984 69.7257C522.537 60.7852 540.195 56.3149 559.876 56.3149C584.032 56.3149 604.676 62.8414 621.898 75.8053C639.121 88.7692 650.537 106.472 656.337 128.913H601.868C597.827 120.24 592.027 113.714 584.645 109.154C577.178 104.594 568.747 102.359 559.256 102.359C543.971 102.359 531.672 107.723 522.181 118.541C512.695 129.359 507.95 143.844 507.95 161.903C507.95 179.964 512.695 194.447 522.181 205.265C531.672 216.083 543.971 221.448 559.256 221.448C568.747 221.448 577.178 219.213 584.645 214.653C592.113 210.093 597.827 203.566 601.868 194.894H656.337C650.537 217.335 639.029 234.948 621.898 247.823C604.676 260.697 584.032 267.134 559.876 267.134C540.195 267.134 522.537 262.664 506.984 253.724C491.434 244.783 479.309 232.356 470.612 216.441C461.914 200.527 457.609 182.378 457.609 161.903C457.609 141.43 461.914 123.191 470.612 107.187Z" fill="#171717"/>
<path d="M711.862 257.747C699.213 250.863 689.287 241.028 681.991 228.243C674.787 215.458 671.096 200.527 671.096 183.451C671.096 166.374 674.787 151.622 682.083 138.748C689.458 125.873 699.477 116.039 712.217 109.154C724.952 102.27 739.274 98.8726 755.086 98.8726C770.905 98.8726 785.221 102.27 797.961 109.154C810.702 116.039 820.714 125.873 828.096 138.748C835.478 151.622 839.077 166.553 839.077 183.451C839.077 200.349 835.386 215.279 827.918 228.153C820.45 241.028 810.346 250.863 797.52 257.747C784.694 264.631 770.377 268.029 754.473 268.029C738.569 268.029 724.424 264.631 711.776 257.747H711.862ZM778.808 213.849C785.484 206.785 788.826 196.683 788.826 183.451C788.826 170.219 785.576 160.205 779.163 153.053C772.658 145.989 764.755 142.413 755.264 142.413C745.773 142.413 737.606 145.9 731.193 152.873C724.78 159.847 721.616 170.039 721.616 183.361C721.616 196.683 724.78 206.696 731.015 213.759C737.251 220.822 745.16 224.399 754.651 224.399C764.136 224.399 772.223 220.822 778.808 213.759V213.849Z" fill="#171717"/>
<path d="M859.284 138.572C865.519 125.787 874.134 115.953 884.937 109.069C895.746 102.184 907.781 98.787 921.135 98.787C931.767 98.787 941.429 101.022 950.215 105.582C959.001 110.142 965.941 116.221 970.944 123.821V47.4678H1020.5V265.618H970.944V242.015C966.291 249.883 959.7 256.141 951.092 260.88C942.484 265.618 932.465 267.943 921.135 267.943C907.781 267.943 895.746 264.456 884.937 257.483C874.134 250.509 865.611 240.585 859.284 227.711C853.049 214.836 849.885 199.994 849.885 183.007C849.885 166.02 853.049 151.268 859.284 138.483V138.572ZM960.669 153.593C953.814 146.351 945.47 142.685 935.629 142.685C925.789 142.685 917.444 146.261 910.59 153.414C903.741 160.567 900.314 170.49 900.314 183.007C900.314 195.524 903.741 205.538 910.59 212.958C917.444 220.29 925.789 224.044 935.629 224.044C945.47 224.044 953.814 220.379 960.669 213.137C967.523 205.895 970.944 195.971 970.944 183.365C970.944 170.759 967.523 160.835 960.669 153.593Z" fill="#171717"/>
<path d="M1202.18 195.52H1090.08C1090.87 205.712 1094.12 213.581 1099.74 218.945C1105.45 224.309 1112.48 227.081 1120.74 227.081C1133.13 227.081 1141.73 221.806 1146.57 211.167H1199.28C1196.56 221.985 1191.73 231.73 1184.61 240.313C1177.58 248.985 1168.71 255.78 1158.08 260.697C1147.45 265.615 1135.58 268.029 1122.41 268.029C1106.6 268.029 1092.45 264.631 1080.15 257.747C1067.76 250.863 1058.1 241.028 1051.16 228.243C1044.22 215.458 1040.7 200.527 1040.7 183.451C1040.7 166.374 1044.13 151.443 1050.98 138.658C1057.84 125.873 1067.41 116.039 1079.8 109.154C1092.19 102.27 1106.33 98.8726 1122.41 98.8726C1138.48 98.8726 1151.93 102.18 1164.14 108.886C1176.26 115.592 1185.84 125.069 1192.69 137.496C1199.54 149.834 1202.97 164.318 1202.97 180.858C1202.97 185.597 1202.71 190.514 1202.09 195.61L1202.18 195.52ZM1152.37 167.536C1152.37 158.864 1149.47 151.98 1143.67 146.883C1137.87 141.787 1130.67 139.194 1121.97 139.194C1113.27 139.194 1106.68 141.609 1100.97 146.526C1095.26 151.443 1091.75 158.417 1090.43 167.447H1152.46L1152.37 167.536Z" fill="#171717"/>
<path d="M1329.92 265.699L1287.67 187.559H1275.8V265.699H1226.25V58.7246H1309.37C1325.44 58.7246 1339.06 61.5855 1350.4 67.3073C1361.73 73.0297 1370.16 80.8077 1375.78 90.7321C1381.41 100.656 1384.22 111.742 1384.22 123.901C1384.22 137.67 1380.44 149.919 1372.8 160.737C1365.15 171.555 1353.91 179.244 1339.06 183.714L1385.97 265.699H1330.01H1329.92ZM1275.72 151.886H1306.47C1315.51 151.886 1322.37 149.651 1326.85 145.091C1331.42 140.531 1333.7 134.183 1333.7 125.958C1333.7 117.732 1331.42 111.921 1326.85 107.362C1322.28 102.802 1315.51 100.566 1306.47 100.566H1275.72V151.886Z" fill="#171717"/>
<path d="M1408.9 138.562C1415.15 125.777 1423.75 115.942 1434.56 109.058C1445.37 102.174 1457.49 99.7598 1470.76 99.7598H1570.12V265.608H1520.57V242.362C1515.74 250.051 1509.06 256.221 1500.45 260.959C1491.84 265.697 1481.83 268.022 1470.49 268.022C1457.32 268.022 1445.37 264.535 1434.56 257.562C1423.75 250.588 1415.23 240.664 1408.9 227.79C1402.67 214.915 1399.51 200.073 1399.51 183.086C1399.51 166.099 1402.67 151.347 1408.9 138.562ZM1510.29 153.582C1503.44 146.341 1495.09 142.675 1485.26 142.675C1475.42 142.675 1467.06 146.251 1460.22 153.403C1453.36 160.556 1449.93 170.48 1449.93 182.997C1449.93 195.514 1453.36 205.527 1460.22 212.948C1467.06 220.279 1475.42 224.034 1485.26 224.034C1495.09 224.034 1503.44 220.369 1510.29 213.127C1517.14 205.885 1520.57 195.961 1520.57 183.355C1520.57 170.748 1517.14 160.824 1510.29 153.582Z" fill="#171717"/>
<path d="M1669.83 105.85C1678.53 101.111 1688.46 98.787 1699.7 98.787C1713.06 98.787 1725.09 102.184 1735.9 109.069C1746.71 115.953 1755.23 125.787 1761.56 138.572C1767.79 151.358 1770.96 166.199 1770.96 183.096C1770.96 199.994 1767.79 214.925 1761.56 227.8C1755.32 240.674 1746.71 250.598 1735.9 257.572C1725.09 264.546 1713.06 268.032 1699.7 268.032C1688.28 268.032 1678.36 265.708 1669.83 261.148C1661.31 256.499 1654.63 250.42 1649.8 242.731V265.708H1600.25V47.4678H1649.8V124.446C1654.46 116.758 1661.14 110.588 1669.83 105.85ZM1710.25 153.503C1703.4 146.351 1694.96 142.774 1684.86 142.774C1674.76 142.774 1666.67 146.44 1659.82 153.682C1652.97 160.924 1649.54 170.848 1649.54 183.454C1649.54 196.06 1652.97 205.985 1659.82 213.226C1666.67 220.468 1675.02 224.134 1684.86 224.134C1694.7 224.134 1703.13 220.468 1710.07 213.048C1717.01 205.717 1720.53 195.703 1720.53 183.096C1720.53 170.49 1717.1 160.656 1710.25 153.503Z" fill="#171717"/>
<path d="M1860.75 105.85C1869.45 101.111 1879.38 98.787 1890.62 98.787C1903.98 98.787 1916.01 102.184 1926.82 109.069C1937.62 115.953 1946.15 125.787 1952.47 138.572C1958.71 151.358 1961.87 166.199 1961.87 183.096C1961.87 199.994 1958.71 214.925 1952.47 227.8C1946.23 240.674 1937.62 250.598 1926.82 257.572C1916.01 264.546 1903.98 268.032 1890.62 268.032C1879.2 268.032 1869.27 265.708 1860.75 261.148C1852.23 256.499 1845.55 250.42 1840.72 242.731V265.708H1791.17V47.4678H1840.72V124.446C1845.37 116.758 1852.05 110.588 1860.75 105.85ZM1901.16 153.503C1894.31 146.351 1885.88 142.774 1875.77 142.774C1865.67 142.774 1857.59 146.44 1850.73 153.682C1843.89 160.924 1840.46 170.848 1840.46 183.454C1840.46 196.06 1843.89 205.985 1850.73 213.226C1857.59 220.468 1865.93 224.134 1875.77 224.134C1885.61 224.134 1894.05 220.468 1900.99 213.048C1907.93 205.717 1911.44 195.703 1911.44 183.096C1911.44 170.49 1908.02 160.656 1901.16 153.503Z" fill="#171717"/>
<path d="M1985.77 76.2514C1980.23 71.0655 1977.51 64.6286 1977.51 56.9396C1977.51 49.2507 1980.23 42.545 1985.77 37.3597C1991.3 32.1737 1998.33 29.5811 2007.02 29.5811C2015.72 29.5811 2022.49 32.1737 2028.02 37.3597C2033.56 42.545 2036.28 49.0714 2036.28 56.9396C2036.28 64.8073 2033.56 71.0655 2028.02 76.2514C2022.49 81.4368 2015.54 84.0294 2007.02 84.0294C1998.5 84.0294 1991.21 81.4368 1985.77 76.2514ZM2031.71 101.195V265.702H1982.16V101.106H2031.71V101.195Z" fill="#171717"/>
<path d="M2151.9 222.875V265.611H2126.68C2108.76 265.611 2094.71 261.141 2084.69 252.2C2074.67 243.26 2069.67 228.686 2069.67 208.391V142.946H2049.99V101.104H2069.67V61.0503H2119.22V101.104H2151.64V142.946H2119.22V209.017C2119.22 213.934 2120.36 217.511 2122.74 219.656C2125.02 221.802 2128.89 222.875 2134.33 222.875H2151.99H2151.9Z" fill="#171717"/>
<path d="M156.512 313C242.944 313 313.012 242.933 313.012 156.5C313.012 70.0674 242.944 0 156.512 0C70.0791 0 0.0117188 70.0674 0.0117188 156.5C0.0117188 242.933 70.0791 313 156.512 313Z" fill="#171717"/>
<path d="M262.787 130.577C262.787 130.577 240.982 102.703 213.572 101.109C195.884 100.065 191.599 102.428 190.831 104.188C189.733 95.0622 181.933 52.7825 128.047 43.8223C134.926 93.2713 163.335 80.3866 180.066 114.469C180.066 114.469 151.833 76.0941 105.417 90.2232C105.417 90.2232 122.335 125.74 172.375 132.997C172.375 132.997 176.385 146.741 177.594 149.161C177.594 149.161 100.528 108.971 77.1277 186.106C59.7129 182.161 53.8716 201.066 73.8876 213.98C73.8876 213.98 77.2934 200.455 85.5866 196.442C85.5866 196.442 67.7896 216.29 88.7175 240.061H163.832C165.647 237.054 173.68 221.236 153.813 209.263C167.837 209.062 179.252 235.515 191.533 240.243H209.396C210 238.774 211.264 234.376 208.296 230.419C203.722 225.172 193.707 225.882 193.796 216.179C197.255 171.04 264.939 184.902 262.787 130.577Z" fill="#FEFEFE"/>
</g>
<defs>
<clipPath id="clip0_97_43">
<rect width="2152" height="313" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 9.2 KiB

View File

@@ -0,0 +1,20 @@
<svg width="2152" height="313" viewBox="0 0 2152 313" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_97_68)">
<path d="M470.612 105.981C479.309 89.978 491.434 77.4609 506.984 68.5204C522.537 59.5799 540.195 55.1096 559.876 55.1096C584.032 55.1096 604.676 61.6361 621.898 74.6C639.121 87.5639 650.537 105.266 656.337 127.707H601.868C597.827 119.035 592.027 112.508 584.645 107.949C577.178 103.389 568.747 101.154 559.256 101.154C543.971 101.154 531.672 106.518 522.181 117.336C512.695 128.154 507.95 142.638 507.95 160.698C507.95 178.758 512.695 193.242 522.181 204.06C531.672 214.878 543.971 220.243 559.256 220.243C568.747 220.243 577.178 218.007 584.645 213.448C592.113 208.888 597.827 202.361 601.868 193.689H656.337C650.537 216.13 639.029 233.743 621.898 246.617C604.676 259.492 584.032 265.929 559.876 265.929C540.195 265.929 522.537 261.459 506.984 252.518C491.434 243.577 479.309 231.15 470.612 215.236C461.914 199.322 457.609 181.172 457.609 160.698C457.609 140.224 461.914 121.985 470.612 105.981Z" fill="#FEFEFE"/>
<path d="M711.862 256.542C699.213 249.658 689.287 239.823 681.991 227.038C674.787 214.253 671.096 199.322 671.096 182.246C671.096 165.169 674.787 150.417 682.083 137.543C689.459 124.668 699.477 114.833 712.217 107.949C724.952 101.065 739.274 97.6675 755.086 97.6675C770.905 97.6675 785.221 101.065 797.961 107.949C810.702 114.833 820.714 124.668 828.096 137.543C835.478 150.417 839.077 165.348 839.077 182.246C839.077 199.143 835.386 214.074 827.918 226.948C820.451 239.823 810.346 249.658 797.52 256.542C784.694 263.426 770.377 266.824 754.473 266.824C738.569 266.824 724.424 263.426 711.776 256.542H711.862ZM778.808 212.644C785.485 205.58 788.826 195.478 788.826 182.246C788.826 169.014 785.577 159 779.163 151.848C772.658 144.784 764.755 141.208 755.264 141.208C745.773 141.208 737.606 144.695 731.193 151.668C724.78 158.642 721.616 168.834 721.616 182.156C721.616 195.478 724.78 205.491 731.015 212.554C737.251 219.617 745.16 223.194 754.651 223.194C764.136 223.194 772.223 219.617 778.808 212.554V212.644Z" fill="#FEFEFE"/>
<path d="M859.284 137.367C865.519 124.582 874.133 114.748 884.937 107.863C895.746 100.979 907.781 97.5817 921.135 97.5817C931.766 97.5817 941.429 99.8164 950.215 104.376C959.001 108.936 965.941 115.016 970.944 122.615V46.2625H1020.5V264.413H970.944V240.81C966.291 248.678 959.7 254.936 951.092 259.675C942.484 264.413 932.465 266.738 921.135 266.738C907.781 266.738 895.746 263.251 884.937 256.277C874.133 249.303 865.611 239.38 859.284 226.505C853.048 213.63 849.885 198.789 849.885 181.802C849.885 164.815 853.048 150.063 859.284 137.278V137.367ZM960.668 152.387C953.814 145.146 945.469 141.48 935.629 141.48C925.788 141.48 917.444 145.056 910.589 152.209C903.741 159.361 900.313 169.285 900.313 181.802C900.313 194.319 903.741 204.333 910.589 211.753C917.444 219.084 925.788 222.839 935.629 222.839C945.469 222.839 953.814 219.173 960.668 211.932C967.523 204.69 970.944 194.766 970.944 182.159C970.944 169.553 967.523 159.629 960.668 152.387Z" fill="#FEFEFE"/>
<path d="M1202.18 194.315H1090.08C1090.87 204.507 1094.12 212.376 1099.74 217.74C1105.45 223.104 1112.48 225.876 1120.74 225.876C1133.13 225.876 1141.73 220.601 1146.57 209.962H1199.28C1196.56 220.78 1191.73 230.525 1184.61 239.108C1177.58 247.78 1168.71 254.575 1158.08 259.492C1147.45 264.41 1135.58 266.824 1122.41 266.824C1106.6 266.824 1092.45 263.426 1080.15 256.542C1067.76 249.658 1058.1 239.823 1051.16 227.038C1044.22 214.253 1040.7 199.322 1040.7 182.246C1040.7 165.169 1044.13 150.238 1050.98 137.453C1057.83 124.668 1067.41 114.833 1079.8 107.949C1092.19 101.065 1106.33 97.6675 1122.41 97.6675C1138.48 97.6675 1151.93 100.975 1164.14 107.681C1176.26 114.387 1185.84 123.864 1192.69 136.291C1199.54 148.629 1202.97 163.113 1202.97 179.653C1202.97 184.392 1202.71 189.309 1202.09 194.405L1202.18 194.315ZM1152.37 166.331C1152.37 157.659 1149.47 150.775 1143.67 145.678C1137.87 140.582 1130.67 137.989 1121.97 137.989C1113.27 137.989 1106.68 140.403 1100.97 145.321C1095.26 150.238 1091.75 157.212 1090.43 166.242H1152.46L1152.37 166.331Z" fill="#FEFEFE"/>
<path d="M1329.92 264.494L1287.67 186.353H1275.8V264.494H1226.25V57.5193H1309.37C1325.44 57.5193 1339.06 60.3802 1350.39 66.102C1361.73 71.8243 1370.16 79.6023 1375.78 89.5268C1381.41 99.4506 1384.22 110.537 1384.22 122.696C1384.22 136.465 1380.44 148.714 1372.8 159.532C1365.15 170.35 1353.91 178.039 1339.06 182.509L1385.97 264.494H1330.01H1329.92ZM1275.72 150.68H1306.47C1315.51 150.68 1322.37 148.445 1326.85 143.885C1331.42 139.326 1333.7 132.978 1333.7 124.752C1333.7 116.527 1331.42 110.716 1326.85 106.156C1322.28 101.596 1315.51 99.361 1306.47 99.361H1275.72V150.68Z" fill="#FEFEFE"/>
<path d="M1408.9 137.357C1415.15 124.572 1423.75 114.737 1434.56 107.853C1445.37 100.968 1457.49 98.5544 1470.76 98.5544H1570.12V264.403H1520.57V241.157C1515.74 248.846 1509.06 255.015 1500.46 259.754C1491.84 264.492 1481.83 266.817 1470.49 266.817C1457.32 266.817 1445.37 263.33 1434.56 256.356C1423.75 249.382 1415.23 239.459 1408.9 226.584C1402.67 213.709 1399.51 198.868 1399.51 181.881C1399.51 164.894 1402.67 150.142 1408.9 137.357ZM1510.29 152.377C1503.44 145.135 1495.09 141.47 1485.26 141.47C1475.42 141.47 1467.07 145.046 1460.22 152.198C1453.36 159.351 1449.93 169.274 1449.93 181.792C1449.93 194.309 1453.36 204.322 1460.22 211.743C1467.07 219.074 1475.42 222.829 1485.26 222.829C1495.09 222.829 1503.44 219.164 1510.29 211.921C1517.14 204.679 1520.57 194.755 1520.57 182.149C1520.57 169.543 1517.14 159.619 1510.29 152.377Z" fill="#FEFEFE"/>
<path d="M1669.83 104.645C1678.53 99.906 1688.46 97.5817 1699.7 97.5817C1713.06 97.5817 1725.09 100.979 1735.9 107.863C1746.71 114.748 1755.23 124.582 1761.56 137.367C1767.79 150.152 1770.95 164.993 1770.95 181.891C1770.95 198.789 1767.79 213.72 1761.56 226.594C1755.32 239.469 1746.71 249.393 1735.9 256.367C1725.09 263.34 1713.06 266.827 1699.7 266.827C1688.28 266.827 1678.36 264.503 1669.83 259.943C1661.31 255.294 1654.63 249.214 1649.8 241.525V264.503H1600.25V46.2625H1649.8V123.241C1654.46 115.552 1661.14 109.383 1669.83 104.645ZM1710.25 152.298C1703.4 145.146 1694.96 141.569 1684.86 141.569C1674.76 141.569 1666.67 145.235 1659.82 152.477C1652.97 159.718 1649.54 169.643 1649.54 182.249C1649.54 194.855 1652.97 204.779 1659.82 212.021C1666.67 219.263 1675.02 222.929 1684.86 222.929C1694.7 222.929 1703.13 219.263 1710.07 211.842C1717.01 204.511 1720.53 194.498 1720.53 181.891C1720.53 169.285 1717.1 159.45 1710.25 152.298Z" fill="#FEFEFE"/>
<path d="M1860.75 104.645C1869.45 99.906 1879.38 97.5817 1890.62 97.5817C1903.98 97.5817 1916.01 100.979 1926.82 107.863C1937.62 114.748 1946.15 124.582 1952.47 137.367C1958.71 150.152 1961.87 164.993 1961.87 181.891C1961.87 198.789 1958.71 213.72 1952.47 226.594C1946.23 239.469 1937.62 249.393 1926.82 256.367C1916.01 263.34 1903.98 266.827 1890.62 266.827C1879.2 266.827 1869.27 264.503 1860.75 259.943C1852.23 255.294 1845.55 249.214 1840.72 241.525V264.503H1791.17V46.2625H1840.72V123.241C1845.37 115.552 1852.05 109.383 1860.75 104.645ZM1901.16 152.298C1894.31 145.146 1885.88 141.569 1875.77 141.569C1865.67 141.569 1857.59 145.235 1850.73 152.477C1843.89 159.718 1840.46 169.643 1840.46 182.249C1840.46 194.855 1843.89 204.779 1850.73 212.021C1857.59 219.263 1865.93 222.929 1875.77 222.929C1885.61 222.929 1894.05 219.263 1900.99 211.842C1907.93 204.511 1911.44 194.498 1911.44 181.891C1911.44 169.285 1908.02 159.45 1901.16 152.298Z" fill="#FEFEFE"/>
<path d="M1985.77 75.0464C1980.23 69.8604 1977.51 63.4236 1977.51 55.7346C1977.51 48.0456 1980.23 41.3399 1985.77 36.1546C1991.3 30.9686 1998.33 28.376 2007.02 28.376C2015.72 28.376 2022.49 30.9686 2028.02 36.1546C2033.56 41.3399 2036.28 47.8664 2036.28 55.7346C2036.28 63.6022 2033.56 69.8604 2028.02 75.0464C2022.49 80.2317 2015.54 82.8243 2007.02 82.8243C1998.5 82.8243 1991.21 80.2317 1985.77 75.0464ZM2031.71 99.9903V264.497H1982.16V99.9007H2031.71V99.9903Z" fill="#FEFEFE"/>
<path d="M2151.9 221.67V264.406H2126.68C2108.76 264.406 2094.71 259.935 2084.69 250.995C2074.67 242.054 2069.67 227.481 2069.67 207.186V141.741H2049.99V99.8987H2069.67V59.845H2119.22V99.8987H2151.64V141.741H2119.22V207.812C2119.22 212.729 2120.36 216.306 2122.74 218.451C2125.02 220.597 2128.89 221.67 2134.33 221.67H2151.99H2151.9Z" fill="#FEFEFE"/>
<path d="M156.512 0C242.944 0 313.012 70.0675 313.012 156.5C313.012 242.933 242.944 313 156.512 313C70.0791 313 0.0117188 242.933 0.0117188 156.5C0.0117188 70.0675 70.0791 0 156.512 0ZM128.045 43.8221C134.924 93.2711 163.334 80.3865 180.063 114.469C180.063 114.469 151.831 76.094 105.415 90.2231C105.42 90.2345 122.341 125.741 172.372 132.997C172.378 133.016 176.384 146.743 177.592 149.16C177.592 149.16 100.526 108.971 77.1258 186.106C59.7109 182.161 53.8696 201.066 73.8854 213.98C73.8854 213.98 77.2913 200.455 85.5845 196.442C85.5514 196.478 67.8071 216.312 88.7156 240.061H163.83C165.645 237.054 173.677 221.236 153.811 209.263C167.834 209.062 179.25 235.514 191.531 240.243H209.395C209.998 238.773 211.261 234.375 208.295 230.419C203.72 225.172 193.705 225.882 193.794 216.178C197.252 171.04 264.936 184.902 262.785 130.577C262.768 130.554 240.968 102.703 213.569 101.109C195.881 100.065 191.597 102.428 190.829 104.188C189.731 95.062 181.931 52.7824 128.045 43.8221Z" fill="#FEFEFE"/>
</g>
<defs>
<clipPath id="clip0_97_68">
<rect width="2152" height="313" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 9.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

View File

@@ -59,6 +59,36 @@ function Hero() {
Open source Self-host in minutes Free tier
</p>
<div className="mt-12 text-center text-xs text-muted-foreground flex flex-col items-center justify-center gap-2">
<p className="text-xs">Proudly sponsored by</p>
<a
href="https://coderabbit.ai/?utm_source=useSend.com"
target="_blank"
>
<Image
src="/code-rabbit-usesend-dark.svg"
alt="Code Rabbit"
width={200}
height={100}
className="dark:hidden"
rel="noopener noreferrer"
/>
</a>
<a
href="https://coderabbit.ai/?utm_source=useSend.com"
target="_blank"
>
<Image
src="/code-rabbit-usesend-light.svg"
alt="Code Rabbit"
width={200}
height={100}
className="hidden dark:block"
rel="noopener noreferrer"
/>
</a>
</div>
<div className=" mt-32 mx-auto max-w-5xl">
<div className="rounded-[18px] bg-primary/10 p-1 sm:p-1 ">
<div className="rounded-2xl bg-primary/20 p-1 sm:p-1 ">

View File

@@ -0,0 +1,19 @@
import type { ReactNode } from "react";
import { SiteFooter } from "~/components/SiteFooter";
import { TopNav } from "~/components/TopNav";
export default function UpdateLayout({
children,
}: {
children: ReactNode;
}) {
return (
<main className="min-h-screen bg-background text-foreground">
<TopNav />
<div className="mx-auto w-full max-w-3xl px-6 py-16">
<article className="space-y-8">{children}</article>
</div>
<SiteFooter />
</main>
);
}

View File

@@ -0,0 +1,66 @@
export const metadata = {
title: "September Outage Update | useSend",
description:
"What happened during the September outage, how we responded, and the improvements now in motion.",
alternates: {
canonical: "https://usesend.com/update/september-outage",
},
openGraph: {
title: "September Outage Update | useSend",
description:
"What happened during the September outage, how we responded, and the improvements now in motion.",
type: "article",
url: "https://usesend.com/update/september-outage",
},
twitter: {
card: "summary_large_image",
title: "September Outage Update | useSend",
description:
"What happened during the September outage, how we responded, and the improvements now in motion.",
},
};
# September Outage Postmortem
On September 17, starting at 11:25 UTC, our emails were not being sent and the outage lasted for almost 10 hours until 21:00 UTC. No emails were sent during this time.
## What happened
Our Amazon SES sending was temporarily paused after compliance signals indicated potential spam characteristics. The initial feedback suggested that some marketing emails might not meet common anti-spam standards (for example, missing unsubscribe links).
## Timeline tldr; (UTC)
- **11:25** - Received an email that our account's sending was paused without prior warning.
- **11:43** - Identified the problematic account, blocked it, and replied to AWS. Paused new signups as well.
- **13:00** - No reply yet, so created a separate escalated support case to get on a call.
- **14:00** - Initial response appeared to interpret us as the sender of the flagged emails; sending was not yet resumed.
- **14:11** - Clarified our product offering again, noting we had blocked the account and paused signups. Shared the useSend site, GitHub, etc.
- **15:53** - Similar response; only valid point is that some marketing emails lacked an unsubscribe link.
- **17:40** - Shipped a change making an unsubscribe link mandatory for marketing emails; shared details on the fix and existing rate limits.
- **18:36** - AWS still not clear with my product and suggestions included adding a CAPTCHA to a form (not applicable to our current flow).
- **19:18** - Re-explained the product and requested senior review for clearer alignment.
- **19:45** - AWS informed that the case would be reviewed within 2-3 business days.
- **19:48** - Requested expedited review due to user impact.
- **21:00** - Finally a valid response with actual steps to improve the product and resumed the account.
## Why
The pause highlighted areas where we can be more diligent about what gets sent through useSend and ensure alignment with SES guidelines and broader email standards.
## What's done till now
- Added a waitlist on signup; we'll screen users before enabling sending.
- Made the unsubscribe link mandatory in the marketing email editor.
- Focusing on users sending transactional and product emails for now.
## Long-term improvements
- More monitoring and pre-send checks (including email screening).
- Double opt-in for contacts.
- A backup SES account to improve resilience.
- Considering a BYO SES option, with useSend managing it for a flat fee.
- Exploring a move to a self-hosted email server (Hard but will try my best).
## To my users
Thank you for being patient and supporting during this time. I'll do a better job in the future to avoid such issues. If you have any suggestions, please do send them in discord or [koushik@usesend.com](mailto:koushik@usesend.com).

View File

@@ -0,0 +1,36 @@
import type { MDXComponents } from "mdx/types";
const components = {
h1: ({ children }) => (
<h1 className="text-3xl font-semibold tracking-wide font-sans text-primary">
{children}
</h1>
),
h2: ({ children }) => (
<h2 className="text-xl font-semibold tracking-wide font-sans text-primary">
{children}
</h2>
),
h3: ({ children }) => (
<h3 className="text-lg font-medium tracking-wide font-sans">{children}</h3>
),
p: ({ children }) => (
<p className="text-base font-normal tracking-wide leading-relaxed font-sans">
{children}
</p>
),
ul: ({ children }) => (
<ul className="list-disc list-inside font-sans pl-4 space-y-1">
{children}
</ul>
),
a: ({ children, href }) => (
<a href={href} className=" text-primary-light">
{children}
</a>
),
} satisfies MDXComponents;
export function useMDXComponents(): MDXComponents {
return components;
}

View File

@@ -0,0 +1,4 @@
export const timeframeOptions = [
{ label: "Today", value: "today" },
{ label: "This month", value: "thisMonth" },
] as const;

View File

@@ -0,0 +1,186 @@
"use client";
import { useMemo, useState } from "react";
import { Card, CardContent, CardHeader, CardTitle } from "@usesend/ui/src/card";
import { Label } from "@usesend/ui/src/label";
import { Switch } from "@usesend/ui/src/switch";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@usesend/ui/src/select";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@usesend/ui/src/table";
import Spinner from "@usesend/ui/src/spinner";
import { api } from "~/trpc/react";
import { isCloud } from "~/utils/common";
import { timeframeOptions } from "./constants";
import { keepPreviousData } from "@tanstack/react-query";
export default function AdminEmailAnalyticsPage() {
const isCloudEnv = isCloud();
const [timeframe, setTimeframe] =
useState<(typeof timeframeOptions)[number]["value"]>("today");
const [paidOnly, setPaidOnly] = useState(false);
const analyticsQuery = api.admin.getEmailAnalytics.useQuery(
{
timeframe,
paidOnly,
},
{ enabled: isCloudEnv, placeholderData: keepPreviousData }
);
const data = analyticsQuery.data;
const totals = data?.totals ?? {
sent: 0,
delivered: 0,
opened: 0,
clicked: 0,
bounced: 0,
complained: 0,
hardBounced: 0,
};
const rows = useMemo(() => data?.rows ?? [], [data]);
if (!isCloudEnv) {
return (
<div className="rounded-lg border bg-muted/30 p-6 text-sm text-muted-foreground">
Email analytics are available only in the cloud deployment.
</div>
);
}
return (
<div className="space-y-6">
<h2 className="text-xl font-semibold">Email analytics</h2>
<div className="flex flex-wrap gap-4">
<div className="w-48">
<Label htmlFor="timeframe">Timeframe</Label>
<Select
value={timeframe}
onValueChange={(value) =>
setTimeframe(value as (typeof timeframeOptions)[number]["value"])
}
>
<SelectTrigger id="timeframe">
<SelectValue placeholder="Select timeframe" />
</SelectTrigger>
<SelectContent>
{timeframeOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex items-center space-x-3">
<Switch checked={paidOnly} onCheckedChange={setPaidOnly} id="paid" />
<Label htmlFor="paid">Paid users only</Label>
</div>
</div>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
<SummaryCard label="Sent" value={totals.sent} />
<SummaryCard label="Delivered" value={totals.delivered} />
<SummaryCard label="Opened" value={totals.opened} />
<SummaryCard label="Clicked" value={totals.clicked} />
<SummaryCard label="Bounced" value={totals.bounced} />
<SummaryCard label="Complained" value={totals.complained} />
<SummaryCard label="Hard bounced" value={totals.hardBounced} />
</div>
<Card className="overflow-hidden">
<CardHeader className="flex flex-row items-center justify-between">
<div>
<CardTitle>Usage by team</CardTitle>
{data ? (
<p className="text-sm text-muted-foreground">
Since {data.timeframe === "today" ? "today" : data.periodStart}
</p>
) : null}
</div>
{analyticsQuery.isLoading ? <Spinner className="h-4 w-4" /> : null}
</CardHeader>
<CardContent className="p-0">
<Table>
<TableHeader>
<TableRow>
<TableHead>Team</TableHead>
<TableHead>Plan</TableHead>
<TableHead className="text-right">Sent</TableHead>
<TableHead className="text-right">Delivered</TableHead>
<TableHead className="text-right">Opened</TableHead>
<TableHead className="text-right">Clicked</TableHead>
<TableHead className="text-right">Bounced</TableHead>
<TableHead className="text-right">Complained</TableHead>
<TableHead className="text-right">Hard bounced</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{analyticsQuery.isLoading ? (
<TableRow>
<TableCell colSpan={9} className="py-12 text-center">
<Spinner className="h-6 w-6" />
</TableCell>
</TableRow>
) : rows.length === 0 ? (
<TableRow>
<TableCell colSpan={9} className="py-12 text-center">
No email activity found for this period.
</TableCell>
</TableRow>
) : (
rows.map((row) => (
<TableRow key={row.teamId}>
<TableCell>{row.name}</TableCell>
<TableCell>{row.plan}</TableCell>
<TableCell className="text-right">{row.sent}</TableCell>
<TableCell className="text-right">
{row.delivered}
</TableCell>
<TableCell className="text-right">{row.opened}</TableCell>
<TableCell className="text-right">{row.clicked}</TableCell>
<TableCell className="text-right">{row.bounced}</TableCell>
<TableCell className="text-right">
{row.complained}
</TableCell>
<TableCell className="text-right">
{row.hardBounced}
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</CardContent>
</Card>
</div>
);
}
function SummaryCard({ label, value }: { label: string; value: number }) {
return (
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">
{label}
</CardTitle>
</CardHeader>
<CardContent>
<p className="text-2xl font-semibold">{value.toLocaleString()}</p>
</CardContent>
</Card>
);
}

View File

@@ -20,6 +20,11 @@ export default function AdminLayout({
Teams
</SettingsNavButton>
) : null}
{isCloud() ? (
<SettingsNavButton href="/admin/email-analytics">
Email analytics
</SettingsNavButton>
) : null}
{isCloud() ? (
<SettingsNavButton href="/admin/waitlist">
Waitlist

View File

@@ -29,34 +29,33 @@ export default function DashboardFilters({
};
return (
<div className="flex gap-3">
<Select
value={domain ?? "All Domains"}
onValueChange={(val) => handleDomain(val)}
>
<SelectTrigger className="w-[180px]">
{domain
? domainsQuery?.find((d) => d.id === Number(domain))?.name
: "All Domains"}
</SelectTrigger>
<SelectContent>
<SelectItem value="All Domains" className="capitalize">
All Domains
</SelectItem>
{domainsQuery &&
domainsQuery.map((domain) => (
<SelectItem key={domain.id} value={domain.id.toString()}>
{domain.name}
</SelectItem>
))}
</SelectContent>
</Select>
<Tabs value={days || "7"} onValueChange={(value) => setDays(value)}>
<TabsList>
<TabsTrigger value="7">7 Days</TabsTrigger>
<TabsTrigger value="30">30 Days</TabsTrigger>
</TabsList>
</Tabs>
</div>
);
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
<Select value={domain ?? "All Domains"} onValueChange={(val) => handleDomain(val)}>
<SelectTrigger className="w-full sm:w-[180px]">
{domain ? domainsQuery?.find((d) => d.id === Number(domain))?.name : "All Domains"}
</SelectTrigger>
<SelectContent>
<SelectItem value="All Domains" className="capitalize">
All Domains
</SelectItem>
{domainsQuery &&
domainsQuery.map((domain) => (
<SelectItem key={domain.id} value={domain.id.toString()}>
{domain.name}
</SelectItem>
))}
</SelectContent>
</Select>
<Tabs value={days || "7"} onValueChange={(value) => setDays(value)}>
<TabsList className="w-full sm:w-auto">
<TabsTrigger value="7" className="flex-1 sm:flex-none">
7 Days
</TabsTrigger>
<TabsTrigger value="30" className="flex-1 sm:flex-none">
30 Days
</TabsTrigger>
</TabsList>
</Tabs>
</div>
);
}

View File

@@ -67,7 +67,7 @@ export default function EmailChart({ days, domain }: EmailChartProps) {
<div className="flex flex-col gap-16">
{!statusQuery.isLoading && statusQuery.data ? (
<div className="w-full h-[450px] border shadow rounded-xl p-4">
<div>
<div className="p-2 overflow-x-auto">
{/* <div className="mb-4 text-sm">Emails</div> */}
<div className="flex gap-10">

View File

@@ -90,8 +90,8 @@ export function ReputationMetrics({ days, domain }: ReputationMetricsProps) {
return (
<TooltipProvider>
<div className="flex gap-10 w-full">
<div className="w-1/2 border rounded-xl shadow p-4">
<div className="flex flex-col sm:flex-row gap-10 w-full">
<div className="w-full sm:w-1/2 border rounded-xl shadow p-4">
<div className="flex justify-between">
<div className=" flex items-center gap-2">
<div className="text-muted-foreground font-mono">Bounce Rate</div>
@@ -240,7 +240,7 @@ export function ReputationMetrics({ days, domain }: ReputationMetricsProps) {
</BarChart>
</ResponsiveContainer>
</div>
<div className="w-1/2 border rounded-xl shadow p-4">
<div className="w-full sm:w-1/2 border rounded-xl shadow p-4">
<div className=" flex items-center gap-2">
<div className=" text-muted-foreground font-mono">
Complaint Rate

View File

@@ -14,6 +14,11 @@ export const waitlistSubmissionSchema = z.object({
emailTypes: z
.array(z.enum(WAITLIST_EMAIL_TYPES))
.min(1, "Select at least one email type"),
emailVolume: z
.string({ required_error: "Share your expected volume" })
.trim()
.min(1, "Tell us how many emails you expect to send")
.max(500, "Keep the volume details under 500 characters"),
description: z
.string({ required_error: "Provide a short description" })
.trim()

View File

@@ -40,6 +40,7 @@ export function WaitListForm({ userEmail }: WaitListFormProps) {
defaultValues: {
domain: "",
emailTypes: [],
emailVolume: "",
description: "",
},
});
@@ -146,6 +147,25 @@ export function WaitListForm({ userEmail }: WaitListFormProps) {
}}
/>
<FormField
control={form.control}
name="emailVolume"
render={({ field }) => (
<FormItem>
<FormLabel>How many emails will you send?</FormLabel>
<FormControl>
<Textarea
rows={3}
placeholder="e.g., Around 400 transactional emails per day and 5,000 marketing emails per month"
{...field}
value={field.value}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="description"

View File

@@ -1,3 +1,4 @@
import { Prisma, type Plan } from "@prisma/client";
import { z } from "zod";
import { env } from "~/env";
@@ -5,6 +6,8 @@ import { createTRPCRouter, adminProcedure } from "~/server/api/trpc";
import { SesSettingsService } from "~/server/service/ses-settings-service";
import { getAccount } from "~/server/aws/ses";
import { db } from "~/server/db";
import { sendMail } from "~/server/mailer";
import { logger } from "~/server/logger/log";
const waitlistUserSelection = {
id: true,
@@ -14,6 +17,28 @@ const waitlistUserSelection = {
createdAt: true,
} as const;
function toPlainHtml(text: string) {
const escaped = text
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
return `<pre style="font-family: inherit; white-space: pre-wrap; margin: 0;">${escaped}</pre>`;
}
function formatDisplayNameFromEmail(email: string) {
const localPart = email.split("@")[0] ?? email;
const pieces = localPart.split(/[._-]+/).filter(Boolean);
if (pieces.length === 0) {
return localPart;
}
return pieces
.map((piece) => piece.charAt(0).toUpperCase() + piece.slice(1))
.join(" ");
}
const teamAdminSelection = {
id: true,
name: true,
@@ -54,7 +79,7 @@ export const adminRouter = createTRPCRouter({
.input(
z.object({
region: z.string(),
}),
})
)
.query(async ({ input }) => {
const acc = await getAccount(input.region);
@@ -68,7 +93,7 @@ export const adminRouter = createTRPCRouter({
usesendUrl: z.string().url(),
sendRate: z.number(),
transactionalQuota: z.number(),
}),
})
)
.mutation(async ({ input }) => {
return SesSettingsService.createSesSetting({
@@ -85,7 +110,7 @@ export const adminRouter = createTRPCRouter({
settingsId: z.string(),
sendRate: z.number(),
transactionalQuota: z.number(),
}),
})
)
.mutation(async ({ input }) => {
return SesSettingsService.updateSesSetting({
@@ -99,11 +124,11 @@ export const adminRouter = createTRPCRouter({
.input(
z.object({
region: z.string().optional().nullable(),
}),
})
)
.query(async ({ input }) => {
return SesSettingsService.getSetting(
input.region ?? env.AWS_DEFAULT_REGION,
input.region ?? env.AWS_DEFAULT_REGION
);
}),
@@ -114,7 +139,7 @@ export const adminRouter = createTRPCRouter({
.string()
.email()
.transform((value) => value.toLowerCase()),
}),
})
)
.mutation(async ({ input }) => {
const user = await db.user.findUnique({
@@ -130,15 +155,62 @@ export const adminRouter = createTRPCRouter({
z.object({
userId: z.number(),
isWaitlisted: z.boolean(),
}),
})
)
.mutation(async ({ input }) => {
const existingUser = await db.user.findUnique({
where: { id: input.userId },
select: waitlistUserSelection,
});
if (!existingUser) {
throw new Error("User not found");
}
const updatedUser = await db.user.update({
where: { id: input.userId },
data: { isWaitlisted: input.isWaitlisted },
select: waitlistUserSelection,
});
const founderEmail = env.FOUNDER_EMAIL ?? undefined;
const fallbackFrom = env.FROM_EMAIL ?? env.ADMIN_EMAIL ?? undefined;
const shouldSendAcceptanceEmail =
existingUser.isWaitlisted &&
!input.isWaitlisted &&
Boolean(updatedUser.email) &&
(founderEmail || fallbackFrom);
if (shouldSendAcceptanceEmail) {
const recipient = updatedUser.email as string;
const replyTo = founderEmail ?? fallbackFrom;
const fromOverride = founderEmail ?? undefined;
const founderName = replyTo
? formatDisplayNameFromEmail(replyTo)
: "Founder";
const userFirstName =
updatedUser.name?.split(" ")[0] ?? updatedUser.name ?? recipient;
const text = `Hey ${userFirstName},\n\nThanks for hanging in while we reviewed your waitlist request. I've just moved your account off the waitlist, so you now have full access to useSend.\n\nGo ahead and log back in to start sending: ${env.NEXTAUTH_URL}\n\nIf anything feels unclear or you want help getting set up, reply to this email and it comes straight to me.\n\nCheers,\n${founderName}\n${replyTo}`;
try {
await sendMail(
recipient,
"useSend: You're off the waitlist",
text,
toPlainHtml(text),
replyTo,
fromOverride
);
} catch (error) {
logger.error(
{ userId: updatedUser.id, error },
"Failed to send waitlist acceptance email"
);
}
}
return updatedUser;
}),
@@ -149,7 +221,7 @@ export const adminRouter = createTRPCRouter({
.string({ required_error: "Search query is required" })
.trim()
.min(1, "Search query is required"),
}),
})
)
.mutation(async ({ input }) => {
const query = input.query.trim();
@@ -205,7 +277,7 @@ export const adminRouter = createTRPCRouter({
dailyEmailLimit: z.number().int().min(0).max(10_000_000),
isBlocked: z.boolean(),
plan: z.enum(["FREE", "BASIC"]),
}),
})
)
.mutation(async ({ input }) => {
const { teamId, ...data } = input;
@@ -218,4 +290,91 @@ export const adminRouter = createTRPCRouter({
return updatedTeam;
}),
getEmailAnalytics: adminProcedure
.input(
z.object({
timeframe: z.enum(["today", "thisMonth"]),
paidOnly: z.boolean().optional(),
})
)
.query(async ({ input }) => {
const timeframe = input.timeframe;
const paidOnly = input.paidOnly ?? false;
const now = new Date();
const today = now.toISOString().slice(0, 10);
const monthStartDate = new Date(
Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1)
);
const monthStart = monthStartDate.toISOString().slice(0, 10);
type EmailAnalyticsRow = {
teamId: number;
name: string;
plan: Plan;
sent: number;
delivered: number;
opened: number;
clicked: number;
bounced: number;
complained: number;
hardBounced: number;
};
const rows = await db.$queryRaw<Array<EmailAnalyticsRow>>`
SELECT
d."teamId" AS "teamId",
t."name" AS name,
t."plan" AS plan,
SUM(d.sent)::integer AS sent,
SUM(d.delivered)::integer AS delivered,
SUM(d.opened)::integer AS opened,
SUM(d.clicked)::integer AS clicked,
SUM(d.bounced)::integer AS bounced,
SUM(d.complained)::integer AS complained,
SUM(d."hardBounced")::integer AS "hardBounced"
FROM "DailyEmailUsage" d
INNER JOIN "Team" t ON t.id = d."teamId"
WHERE 1 = 1
${
timeframe === "today"
? Prisma.sql`AND d."date" = ${today}`
: Prisma.sql`AND d."date" >= ${monthStart}`
}
${paidOnly ? Prisma.sql`AND t."plan" = 'BASIC'` : Prisma.sql``}
GROUP BY d."teamId", t."name", t."plan"
ORDER BY sent DESC
`;
const totals = rows.reduce(
(acc, row) => {
acc.sent += row.sent;
acc.delivered += row.delivered;
acc.opened += row.opened;
acc.clicked += row.clicked;
acc.bounced += row.bounced;
acc.complained += row.complained;
acc.hardBounced += row.hardBounced;
return acc;
},
{
sent: 0,
delivered: 0,
opened: 0,
clicked: 0,
bounced: 0,
complained: 0,
hardBounced: 0,
}
);
return {
rows,
totals,
timeframe,
paidOnly,
periodStart: timeframe === "today" ? today : monthStart,
};
}),
});

View File

@@ -75,11 +75,14 @@ export const waitlistRouter = createTRPCRouter({
const escapedDescription = escapeHtml(input.description);
const escapedDomain = escapeHtml(input.domain);
const escapedEmailVolume = escapeHtml(input.emailVolume);
const subject = `Waitlist request from ${user.email ?? "unknown user"}`;
const textBody = `A waitlisted user submitted a request:\n\nEmail: ${
user.email ?? "Unknown"
}\nDomain: ${input.domain}\nInterested emails: ${typesLabel}\n\nDescription:\n${input.description}`;
}\nDomain: ${input.domain}\nInterested emails: ${typesLabel}\nExpected sending volume: ${
input.emailVolume
}\n\nDescription:\n${input.description}`;
const htmlBody = `
<p>A waitlisted user submitted a request.</p>
@@ -87,6 +90,7 @@ export const waitlistRouter = createTRPCRouter({
<li><strong>Email:</strong> ${escapeHtml(user.email ?? "Unknown")}</li>
<li><strong>Domain:</strong> ${escapedDomain}</li>
<li><strong>Interested emails:</strong> ${escapeHtml(typesLabel)}</li>
<li><strong>Expected sending volume:</strong> ${escapedEmailVolume}</li>
</ul>
<p><strong>Description</strong></p>
<p style="white-space: pre-wrap;">${escapedDescription}</p>

View File

@@ -1,7 +1,9 @@
import Stripe from "stripe";
import { env } from "~/env";
import { db } from "../db";
import { sendSubscriptionConfirmationEmail } from "../mailer";
import { TeamService } from "../service/team-service";
import { logger } from "../logger/log";
export function getStripe() {
if (!env.STRIPE_SECRET_KEY) {
@@ -123,6 +125,8 @@ export async function syncStripeData(customerId: string) {
return;
}
const wasPaid = team.isActive && team.plan !== "FREE";
const subscriptions = await stripe.subscriptions.list({
customer: customerId,
limit: 1,
@@ -144,6 +148,10 @@ export async function syncStripeData(customerId: string) {
.map((item) => item.price?.id)
.filter((id): id is string => Boolean(id));
const nextPlan = getPlanFromPriceIds(priceIds);
const isNowPaid = subscription.status === "active" && nextPlan !== "FREE";
const shouldSendSubscriptionConfirmation = !wasPaid && isNowPaid;
await db.subscription.upsert({
where: { id: subscription.id },
update: {
@@ -182,10 +190,24 @@ export async function syncStripeData(customerId: string) {
});
await TeamService.updateTeam(team.id, {
plan:
subscription.status === "canceled"
? "FREE"
: getPlanFromPriceIds(priceIds),
plan: subscription.status === "canceled" ? "FREE" : nextPlan,
isActive: subscription.status === "active",
});
if (shouldSendSubscriptionConfirmation) {
try {
const teamUsers = await TeamService.getTeamUsers(team.id);
await Promise.all(
teamUsers
.map((tu) => tu.user?.email)
.filter((email): email is string => Boolean(email))
.map((email) => sendSubscriptionConfirmationEmail(email))
);
} catch (err) {
logger.error(
{ err, teamId: team.id },
"[Billing]: Failed sending subscription confirmation email"
);
}
}
}

View File

@@ -69,12 +69,26 @@ export async function sendTeamInviteEmail(
await sendMail(email, subject, text, html);
}
export async function sendSubscriptionConfirmationEmail(email: string) {
if (!env.FOUNDER_EMAIL) {
logger.error("FOUNDER_EMAIL not configured");
return;
}
const subject = "Thanks for subscribing to useSend";
const text = `Hey,\n\nThanks for subscribing to useSend, just wanted to let you know you can join the discord server to have a dedicated support channel for your team. So that we can address your queries / bugs asap.\n\nYou can join over using the link: https://discord.com/invite/BU8n8pJv8S\n\nIf you prefer slack, please let me know\n\ncheers,\nkoushik - useSend`;
const html = text.replace(/\n/g, "<br />");
await sendMail(email, subject, text, html, undefined, env.FOUNDER_EMAIL);
}
export async function sendMail(
email: string,
subject: string,
text: string,
html: string,
replyTo?: string
replyTo?: string,
fromOverride?: string
) {
if (isSelfHosted()) {
logger.info("Sending email using self hosted");
@@ -96,24 +110,33 @@ export async function sendMail(
return;
}
const fromEmailDomain = env.FROM_EMAIL?.split("@")[1];
const availableDomains = domains.map((d) => d.name);
const domain = domains[0];
const domain =
domains.find((d) => d.name === fromEmailDomain) ?? domains[0];
const candidateFroms = [fromOverride, env.FROM_EMAIL, `hello@${domain.name}`].filter(
(value): value is string => Boolean(value)
);
const selectedFrom =
candidateFroms.find((address) => {
const domainPart = address.split("@")[1];
return domainPart ? availableDomains.includes(domainPart) : false;
}) ?? `hello@${domain.name}`;
await sendEmail({
teamId: team.id,
to: email,
from: `hello@${domain.name}`,
from: selectedFrom,
subject,
text,
html,
replyTo,
});
} else if (env.UNSEND_API_KEY && env.FROM_EMAIL) {
} else if (env.UNSEND_API_KEY && (env.FROM_EMAIL || fromOverride)) {
const fromAddress = fromOverride ?? env.FROM_EMAIL!;
const resp = await getClient().emails.send({
to: email,
from: env.FROM_EMAIL,
from: fromAddress,
subject,
text,
html,

View File

@@ -563,6 +563,7 @@ export class TeamService {
"[TeamService]: Set warning notification cooldown"
);
}
}
async function getLimitReachedEmail(

View File

@@ -6,9 +6,9 @@ services:
container_name: unsend-db-dev
restart: always
environment:
- POSTGRES_USER=unsend
- POSTGRES_USER=usesend
- POSTGRES_PASSWORD=password
- POSTGRES_DB=unsend
- POSTGRES_DB=usesend
volumes:
- database:/var/lib/postgresql/data
ports:

1120
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff