diff --git a/api/bun.lock b/api/bun.lock index 3d81434..a12e080 100644 --- a/api/bun.lock +++ b/api/bun.lock @@ -12,8 +12,10 @@ "cors": "^2.8.5", "crypto": "^1.0.1", "dotenv": "^17.2.3", + "email-reply-parser": "^2.1.0", "express": "^5.1.0", "express-validator": "^7.3.0", + "mailparser": "^3.9.0", "mongoose": "^8.19.2", "passport": "^0.7.0", "passport-http-bearer": "^1.0.1", @@ -22,6 +24,8 @@ "@types/bun": "latest", "@types/cookie-parser": "^1.4.10", "@types/cors": "^2.8.19", + "@types/email-reply-parser": "^1.4.2", + "@types/mailparser": "^3.4.6", "@types/passport": "^1.0.17", "@types/passport-http-bearer": "^1.0.42", }, @@ -93,6 +97,8 @@ "@ruptjs/core": ["@ruptjs/core@0.1.0", "", {}, "sha512-IinmXHYGexNrea74f5JiI6AF3cYvA6pHhZaOo/HtAUw+ttC32YdugKWq0JpMaXeu52kKIZhUrnLaPxr63ajuEQ=="], + "@selderee/plugin-htmlparser2": ["@selderee/plugin-htmlparser2@0.11.0", "", { "dependencies": { "domhandler": "^5.0.3", "selderee": "^0.11.0" } }, "sha512-P33hHGdldxGabLFjPPpaTxVolMrzrcegejx+0GxjrIb9Zv48D8yAIA/QTDR2dFl7Uz7urX8aX6+5bCZslr+gWQ=="], + "@smithy/abort-controller": ["@smithy/abort-controller@4.2.4", "", { "dependencies": { "@smithy/types": "^4.8.1", "tslib": "^2.6.2" } }, "sha512-Z4DUr/AkgyFf1bOThW2HwzREagee0sB5ycl+hDiSZOfRLW8ZgrOjDi6g8mHH19yyU5E2A/64W3z6SMIf5XiUSQ=="], "@smithy/config-resolver": ["@smithy/config-resolver@4.4.1", "", { "dependencies": { "@smithy/node-config-provider": "^4.3.4", "@smithy/types": "^4.8.1", "@smithy/util-config-provider": "^4.2.0", "@smithy/util-endpoints": "^3.2.4", "@smithy/util-middleware": "^4.2.4", "tslib": "^2.6.2" } }, "sha512-BciDJ5hkyYEGBBKMbjGB1A/Zq8bYZ41Zo9BMnGdKF6QD1fY4zIkYx6zui/0CHaVGnv6h0iy8y4rnPX9CPCAPyQ=="], @@ -191,6 +197,8 @@ "@types/cors": ["@types/cors@2.8.19", "", { "dependencies": { "@types/node": "*" } }, "sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg=="], + "@types/email-reply-parser": ["@types/email-reply-parser@1.4.2", "", {}, "sha512-kmMoK9WMX4zXf3c0D3tkWHDl0E50V2dv6fVirdTQd/mkvE/Jixh0DZAh3kBgpltr1eaWM3W+kAf4A2c2Z2iU2A=="], + "@types/express": ["@types/express@5.0.5", "", { "dependencies": { "@types/body-parser": "*", "@types/express-serve-static-core": "^5.0.0", "@types/serve-static": "^1" } }, "sha512-LuIQOcb6UmnF7C1PCFmEU1u2hmiHL43fgFQX67sN3H4Z+0Yk0Neo++mFsBjhOAuLzvlQeqAAkeDOZrJs9rzumQ=="], "@types/express-serve-static-core": ["@types/express-serve-static-core@5.1.0", "", { "dependencies": { "@types/node": "*", "@types/qs": "*", "@types/range-parser": "*", "@types/send": "*" } }, "sha512-jnHMsrd0Mwa9Cf4IdOzbz543y4XJepXrbia2T4b6+spXC2We3t1y6K44D3mR8XMFSXMCf3/l7rCgddfx7UNVBA=="], @@ -205,6 +213,8 @@ "@types/koa-compose": ["@types/koa-compose@3.2.9", "", { "dependencies": { "@types/koa": "*" } }, "sha512-BroAZ9FTvPiCy0Pi8tjD1OfJ7bgU1gQf0eR6e1Vm+JJATy9eKOG3hQMFtMciMawiSOVnLMdmUOC46s7HBhSTsA=="], + "@types/mailparser": ["@types/mailparser@3.4.6", "", { "dependencies": { "@types/node": "*", "iconv-lite": "^0.6.3" } }, "sha512-wVV3cnIKzxTffaPH8iRnddX1zahbYB1ZEoAxyhoBo3TBCBuK6nZ8M8JYO/RhsCuuBVOw/DEN/t/ENbruwlxn6Q=="], + "@types/mime": ["@types/mime@1.3.5", "", {}, "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w=="], "@types/node": ["@types/node@24.10.0", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-qzQZRBqkFsYyaSWXuEHc2WR9c0a0CXwiE5FWUvn7ZM+vdy1uZLfCunD38UzhuB7YN/J11ndbDBcTmOdxJo9Q7A=="], @@ -227,6 +237,8 @@ "@types/whatwg-url": ["@types/whatwg-url@11.0.5", "", { "dependencies": { "@types/webidl-conversions": "*" } }, "sha512-coYR071JRaHa+xoEvvYqvnIHaVqaYrLPbsufM9BF63HkwI5Lgmy2QR8Q5K/lYDYo5AK82wOvSOS0UsLTpTG7uQ=="], + "@zone-eu/mailsplit": ["@zone-eu/mailsplit@5.4.7", "", { "dependencies": { "libbase64": "1.3.0", "libmime": "5.3.7", "libqp": "2.1.1" } }, "sha512-jApX86aDgolMz08pP20/J2zcns02NSK3zSiYouf01QQg4250L+GUAWSWicmS7eRvs+Z7wP7QfXrnkaTBGrIpwQ=="], + "accepts": ["accepts@2.0.0", "", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="], "asynckit": ["asynckit@0.4.0", "", {}, "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="], @@ -267,18 +279,34 @@ "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], + "deepmerge": ["deepmerge@4.3.1", "", {}, "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A=="], + "delayed-stream": ["delayed-stream@1.0.0", "", {}, "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ=="], "depd": ["depd@2.0.0", "", {}, "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw=="], + "dom-serializer": ["dom-serializer@2.0.0", "", { "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.2", "entities": "^4.2.0" } }, "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg=="], + + "domelementtype": ["domelementtype@2.3.0", "", {}, "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw=="], + + "domhandler": ["domhandler@5.0.3", "", { "dependencies": { "domelementtype": "^2.3.0" } }, "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w=="], + + "domutils": ["domutils@3.2.2", "", { "dependencies": { "dom-serializer": "^2.0.0", "domelementtype": "^2.3.0", "domhandler": "^5.0.3" } }, "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw=="], + "dotenv": ["dotenv@17.2.3", "", {}, "sha512-JVUnt+DUIzu87TABbhPmNfVdBDt18BLOWjMUFJMSi/Qqg7NTYtabbvSNJGOJ7afbRuv9D/lngizHtP7QyLQ+9w=="], "dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="], "ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="], + "email-reply-parser": ["email-reply-parser@2.1.0", "", { "peerDependencies": { "re2": "1.22.1" }, "optionalPeers": ["re2"] }, "sha512-qhfEbffLJgXTrGpGKxgh4BB/6rY5pHZrnaum141aXBzJOK9JnXjfOayr4qI9gnuRg6Z+bPYfg4fHdKtiuZzC/Q=="], + "encodeurl": ["encodeurl@2.0.0", "", {}, "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg=="], + "encoding-japanese": ["encoding-japanese@2.2.0", "", {}, "sha512-EuJWwlHPZ1LbADuKTClvHtwbaFn4rOD+dRAbWysqEOXRc2Uui0hJInNJrsdH0c+OhJA4nrCBdSkW4DD5YxAo6A=="], + + "entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="], + "es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="], "es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="], @@ -321,6 +349,12 @@ "hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="], + "he": ["he@1.2.0", "", { "bin": { "he": "bin/he" } }, "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw=="], + + "html-to-text": ["html-to-text@9.0.5", "", { "dependencies": { "@selderee/plugin-htmlparser2": "^0.11.0", "deepmerge": "^4.3.1", "dom-serializer": "^2.0.0", "htmlparser2": "^8.0.2", "selderee": "^0.11.0" } }, "sha512-qY60FjREgVZL03vJU6IfMV4GDjGBIoOyvuFdpBDIX9yTlDw0TjxVBQp+P8NvpdIXNJvfWBTNul7fsAQJq2FNpg=="], + + "htmlparser2": ["htmlparser2@8.0.2", "", { "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.3", "domutils": "^3.0.1", "entities": "^4.4.0" } }, "sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA=="], + "http-errors": ["http-errors@2.0.0", "", { "dependencies": { "depd": "2.0.0", "inherits": "2.0.4", "setprototypeof": "1.2.0", "statuses": "2.0.1", "toidentifier": "1.0.1" } }, "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ=="], "iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="], @@ -333,8 +367,20 @@ "kareem": ["kareem@2.6.3", "", {}, "sha512-C3iHfuGUXK2u8/ipq9LfjFfXFxAZMQJJq7vLS45r3D9Y2xQ/m4S8zaR4zMLFWh9AsNPXmcFfUDhTEO8UIC/V6Q=="], + "leac": ["leac@0.6.0", "", {}, "sha512-y+SqErxb8h7nE/fiEX07jsbuhrpO9lL8eca7/Y1nuWV2moNlXhyd59iDGcRf6moVyDMbmTNzL40SUyrFU/yDpg=="], + + "libbase64": ["libbase64@1.3.0", "", {}, "sha512-GgOXd0Eo6phYgh0DJtjQ2tO8dc0IVINtZJeARPeiIJqge+HdsWSuaDTe8ztQ7j/cONByDZ3zeB325AHiv5O0dg=="], + + "libmime": ["libmime@5.3.7", "", { "dependencies": { "encoding-japanese": "2.2.0", "iconv-lite": "0.6.3", "libbase64": "1.3.0", "libqp": "2.1.1" } }, "sha512-FlDb3Wtha8P01kTL3P9M+ZDNDWPKPmKHWaU/cG/lg5pfuAwdflVpZE+wm9m7pKmC5ww6s+zTxBKS1p6yl3KpSw=="], + + "libqp": ["libqp@2.1.1", "", {}, "sha512-0Wd+GPz1O134cP62YU2GTOPNA7Qgl09XwCqM5zpBv87ERCXdfDtyKXvV7c9U22yWJh44QZqBocFnXN11K96qow=="], + + "linkify-it": ["linkify-it@5.0.0", "", { "dependencies": { "uc.micro": "^2.0.0" } }, "sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ=="], + "lodash": ["lodash@4.17.21", "", {}, "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg=="], + "mailparser": ["mailparser@3.9.0", "", { "dependencies": { "@zone-eu/mailsplit": "5.4.7", "encoding-japanese": "2.2.0", "he": "1.2.0", "html-to-text": "9.0.5", "iconv-lite": "0.7.0", "libmime": "5.3.7", "linkify-it": "5.0.0", "nodemailer": "7.0.10", "punycode.js": "2.3.1", "tlds": "1.261.0" } }, "sha512-jpaNLhDjwy0w2f8sySOSRiWREjPqssSc0C2czV98btCXCRX3EyNloQ2IWirmMDj1Ies8Fkm0l96bZBZpDG7qkg=="], + "math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="], "media-typer": ["media-typer@1.1.0", "", {}, "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw=="], @@ -361,6 +407,8 @@ "negotiator": ["negotiator@1.0.0", "", {}, "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg=="], + "nodemailer": ["nodemailer@7.0.10", "", {}, "sha512-Us/Se1WtT0ylXgNFfyFSx4LElllVLJXQjWi2Xz17xWw7amDKO2MLtFnVp1WACy7GkVGs+oBlRopVNUzlrGSw1w=="], + "object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="], "object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="], @@ -369,6 +417,8 @@ "once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="], + "parseley": ["parseley@0.12.1", "", { "dependencies": { "leac": "^0.6.0", "peberminta": "^0.9.0" } }, "sha512-e6qHKe3a9HWr0oMRVDTRhKce+bRO8VGQR3NyVwcjwrbhMmFCX9KszEV35+rn4AdilFAq9VPxP/Fe1wC9Qjd2lw=="], + "parseurl": ["parseurl@1.3.3", "", {}, "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ=="], "passport": ["passport@0.7.0", "", { "dependencies": { "passport-strategy": "1.x.x", "pause": "0.0.1", "utils-merge": "^1.0.1" } }, "sha512-cPLl+qZpSc+ireUvt+IzqbED1cHHkDoVYMo30jbJIdOOjQ1MQYZBPiNvmi8UM6lJuOpTPXJGZQk0DtC4y61MYQ=="], @@ -381,12 +431,16 @@ "pause": ["pause@0.0.1", "", {}, "sha512-KG8UEiEVkR3wGEb4m5yZkVCzigAD+cVEJck2CzYZO37ZGJfctvVptVO192MwrtPhzONn6go8ylnOdMhKqi4nfg=="], + "peberminta": ["peberminta@0.9.0", "", {}, "sha512-XIxfHpEuSJbITd1H3EeQwpcZbTLHc+VVr8ANI9t5sit565tsI4/xK3KWTUFE2e6QiangUkh3B0jihzmGnNrRsQ=="], + "proxy-addr": ["proxy-addr@2.0.7", "", { "dependencies": { "forwarded": "0.2.0", "ipaddr.js": "1.9.1" } }, "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg=="], "proxy-from-env": ["proxy-from-env@1.1.0", "", {}, "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg=="], "punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="], + "punycode.js": ["punycode.js@2.3.1", "", {}, "sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA=="], + "qs": ["qs@6.14.0", "", { "dependencies": { "side-channel": "^1.1.0" } }, "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w=="], "range-parser": ["range-parser@1.2.1", "", {}, "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg=="], @@ -399,6 +453,8 @@ "safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="], + "selderee": ["selderee@0.11.0", "", { "dependencies": { "parseley": "^0.12.0" } }, "sha512-5TF+l7p4+OsnP8BCCvSyZiSPc4x4//p5uPwK8TCnVPJYRmU2aYKMpOXvw8zM5a5JvuuCGN1jmsMwuU2W02ukfA=="], + "send": ["send@1.2.0", "", { "dependencies": { "debug": "^4.3.5", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "fresh": "^2.0.0", "http-errors": "^2.0.0", "mime-types": "^3.0.1", "ms": "^2.1.3", "on-finished": "^2.4.1", "range-parser": "^1.2.1", "statuses": "^2.0.1" } }, "sha512-uaW0WwXKpL9blXE2o0bRhoL2EGXIrZxQ2ZQ4mgcfoBxdFmQold+qWsD2jLrfZ0trjKL6vOw0j//eAwcALFjKSw=="], "serve-static": ["serve-static@2.2.0", "", { "dependencies": { "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "parseurl": "^1.3.3", "send": "^1.2.0" } }, "sha512-61g9pCh0Vnh7IutZjtLGGpTA355+OPn2TyDv/6ivP2h/AdAVX9azsoxmg2/M6nZeQZNYBEwIcsne1mJd9oQItQ=="], @@ -421,6 +477,8 @@ "strnum": ["strnum@2.1.1", "", {}, "sha512-7ZvoFTiCnGxBtDqJ//Cu6fWtZtc7Y3x+QOirG15wztbdngGSkht27o2pyGWrVy0b4WAy3jbKmnoK6g5VlVNUUw=="], + "tlds": ["tlds@1.261.0", "", { "bin": { "tlds": "bin.js" } }, "sha512-QXqwfEl9ddlGBaRFXIvNKK6OhipSiLXuRuLJX5DErz0o0Q0rYxulWLdFryTkV5PkdZct5iMInwYEGe/eR++1AA=="], + "toidentifier": ["toidentifier@1.0.1", "", {}, "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA=="], "tr46": ["tr46@5.1.1", "", { "dependencies": { "punycode": "^2.3.1" } }, "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw=="], @@ -431,6 +489,8 @@ "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + "uc.micro": ["uc.micro@2.1.0", "", {}, "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A=="], + "undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], "unpipe": ["unpipe@1.0.0", "", {}, "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ=="], @@ -457,6 +517,8 @@ "http-errors/statuses": ["statuses@2.0.1", "", {}, "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ=="], + "mailparser/iconv-lite": ["iconv-lite@0.7.0", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-cf6L2Ds3h57VVmkZe+Pn+5APsT7FpqJtEhhieDCvrE2MK5Qk9MyffgQyuxQTm6BChfeZNtcOLHp9IcWRVcIcBQ=="], + "raw-body/iconv-lite": ["iconv-lite@0.7.0", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-cf6L2Ds3h57VVmkZe+Pn+5APsT7FpqJtEhhieDCvrE2MK5Qk9MyffgQyuxQTm6BChfeZNtcOLHp9IcWRVcIcBQ=="], "@aws-crypto/sha256-browser/@smithy/util-utf8/@smithy/util-buffer-from": ["@smithy/util-buffer-from@2.2.0", "", { "dependencies": { "@smithy/is-array-buffer": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA=="], diff --git a/api/controllers/AuthController.ts b/api/controllers/AuthController.ts index 80570bc..479030f 100644 --- a/api/controllers/AuthController.ts +++ b/api/controllers/AuthController.ts @@ -36,12 +36,9 @@ export async function register( name: "Default API Key", }); - const inboxEmail = await getNewRandomInboxEmail({ name: "inbox" }); - const inbox = await createInbox({ organization_id: organization.id, name: "My Inbox", - email: inboxEmail, }); return { user, organization, apiKey, inbox }; diff --git a/api/controllers/InboxController.ts b/api/controllers/InboxController.ts index d5cadb2..7bbb991 100644 --- a/api/controllers/InboxController.ts +++ b/api/controllers/InboxController.ts @@ -10,7 +10,7 @@ export async function createInbox({ organization_id: string; domain_id?: string; name: string; - email: string; + email?: string; }) { const inbox = new Inbox(); inbox.organizationId = new mongoose.Types.ObjectId(organization_id); @@ -18,7 +18,7 @@ export async function createInbox({ ? new mongoose.Types.ObjectId(domain_id) : undefined; inbox.name = name; - inbox.email = email; + inbox.email = email || await getNewRandomInboxEmail({ name }); await inbox.save(); return inbox; } diff --git a/api/controllers/MessageController.ts b/api/controllers/MessageController.ts index f72817c..977ce46 100644 --- a/api/controllers/MessageController.ts +++ b/api/controllers/MessageController.ts @@ -1,37 +1,47 @@ import mongoose from "mongoose"; import Message from "../db/mongo/schemas/Message"; +import type { MessageStatus } from "../models/Message"; export async function createMessage({ organizationId, inboxId, + threadId, fromInboxId, toInboxId, from, to, + externalMessageId, subject, text, html, + status, }: { organizationId: string; inboxId: string; + threadId: string; fromInboxId?: string; toInboxId?: string; from: string; to: string; + externalMessageId?: string; subject: string; text: string; html: string; + status?: (typeof MessageStatus)[number]; }) { const message = new Message(); message.organizationId = new mongoose.Types.ObjectId(organizationId); message.inboxId = new mongoose.Types.ObjectId(inboxId); + message.threadId = new mongoose.Types.ObjectId(threadId); message.fromInboxId = fromInboxId ? new mongoose.Types.ObjectId(fromInboxId) : undefined; message.toInboxId = toInboxId ? new mongoose.Types.ObjectId(toInboxId) : undefined; message.from = from; message.to = to; + message.externalMessageId = externalMessageId; message.subject = subject; message.text = text; message.html = html; + message.status = status; await message.save(); return message; } @@ -39,3 +49,11 @@ export async function createMessage({ export async function getMessageById(messageId: string) { return await Message.findById(messageId); } + +export async function getMessagesByInboxId(inboxId: string) { + return await Message.find({ inboxId: new mongoose.Types.ObjectId(inboxId) }); +} + +export async function getMessageByExternalMessageId(externalMessageId: string) { + return await Message.findOne({ externalMessageId }); +} diff --git a/api/controllers/SESController.ts b/api/controllers/SESController.ts index db33b8a..3708950 100644 --- a/api/controllers/SESController.ts +++ b/api/controllers/SESController.ts @@ -5,10 +5,14 @@ import { VerifyDomainDkimCommand, } from "@aws-sdk/client-ses"; import type { SNSMessage } from "../models/SES"; -import { getMessageById } from "./MessageController"; +import { createMessage, getMessageByExternalMessageId, getMessageById } from "./MessageController"; import type { MessageStatus } from "../models/Message"; import type { WebhookEvents } from "../models/Webhook"; import { sendWebhookEvent } from "./WebhookAttemptController"; +import { getInboxByEmail } from "./InboxController"; +import { simpleParser } from "mailparser"; +import EmailReplyParser from "email-reply-parser"; +import { addMessageToThread, createThread } from "./ThreadController"; export const ses = new SESClient({ region: "us-east-2", @@ -46,7 +50,6 @@ export async function sendSESMessage({ text: string; html: string; }) { - console.log("Sending SES message", { messageId, from, to, fromName, subject, text, html }); const command = new SendEmailCommand({ Source: fromName ? `${fromName} <${from}>` : from, Destination: { @@ -82,49 +85,136 @@ export async function sendSESMessage({ export async function handleDeliveryNotification(rawMessage: string) { try { const notification: SNSMessage = JSON.parse(rawMessage); + console.log("notification", notification); + const messageId = notification.mail?.tags?.["message"]?.[0]; - if (!messageId) { - console.error( - "No sendook:message tag found in SES delivery notification" - ); - return; - } - const message = await getMessageById(messageId); - if (!message) { - console.error("Message not found", messageId); + if (messageId) { + await handleOutboundSESMessage({ + notification, + messageId, + }); return; } - let status: (typeof MessageStatus)[number] | undefined; - let event: (typeof WebhookEvents)[number] | undefined; - if (notification.eventType === "Reject") { - status = "rejected"; - event = "message.rejected"; - } else if (notification.eventType === "Bounce") { - status = "bounced"; - event = "message.bounced"; - } else if (notification.eventType === "Complaint") { - status = "complained"; - event = "message.complained"; - } else if (notification.eventType === "Delivery") { - status = "delivered"; - event = "message.delivered"; + await handleInboundSESMessage({ + notification, + }); + } catch (error) { + console.error("Error handling SES delivery notification", error); + return; + } +} + +export async function handleInboundSESMessage({ + notification, +}: { + notification: SNSMessage; +}) { + if (!notification.mail.destination[0]) { + return; + } + + const inbox = await getInboxByEmail(notification.mail.destination[0]); + if (!inbox) { + console.error("Inbox not found", notification.mail.destination[0]); + return; + } + + const mail = await simpleParser(Buffer.from(notification.content, "base64").toString("utf-8")); + const content = new EmailReplyParser().read(mail.text ?? ""); + + const fromInboxId = await getInboxByEmail(notification.mail.source); + + const reference = notification.mail.headers?.find(header => header.name === "References")?.value; + const replyToMessageId = reference?.match(/<([^@>]+)@us-east-2\.amazonses\.com>/)?.[1]; + + let threadId: string | undefined; + if (replyToMessageId) { + const message = await getMessageByExternalMessageId(replyToMessageId); + if (message) { + threadId = message.threadId.toString(); } + } + + if (!threadId) { + const thread = await createThread({ + organizationId: inbox.organizationId.toString(), + inboxId: inbox.id, + }); + threadId = thread._id.toString(); + } + const message = await createMessage({ + organizationId: inbox.organizationId.toString(), + inboxId: inbox.id, + threadId, + from: notification.mail.source, + fromInboxId: fromInboxId?.id, + to: notification.mail.destination[0], + toInboxId: inbox.id, + subject: notification.mail.commonHeaders?.subject, + text: content.getVisibleText(), + html: content.getVisibleText(), + status: "received", + }); + + await addMessageToThread({ + threadId, + messageId: message._id.toString(), + }); + + await sendWebhookEvent({ + organizationId: inbox.organizationId.toString(), + inboxId: inbox.id, + messageId: message.id, + event: "message.received", + payload: message, + }); +} + +export async function handleOutboundSESMessage({ + notification, + messageId, +}: { + notification: SNSMessage; + messageId: string; +}) { + const message = await getMessageById(messageId); + if (!message) { + console.error("Message not found", messageId); + return; + } + + let status: (typeof MessageStatus)[number] | undefined; + let event: (typeof WebhookEvents)[number] | undefined; + if (notification.eventType === "Reject") { + status = "rejected"; + event = "message.rejected"; + } else if (notification.eventType === "Bounce") { + status = "bounced"; + event = "message.bounced"; + } else if (notification.eventType === "Complaint") { + status = "complained"; + event = "message.complained"; + } else if (notification.eventType === "Delivery") { + status = "delivered"; + event = "message.delivered"; + } + + if (status) { message.status = status; await message.save(); + } - if (event) { - await sendWebhookEvent({ - organizationId: message.organizationId.toString(), - inboxId: message.inboxId.toString(), - messageId: message.id, - event, - payload: message, - }); - } - } catch (error) { - console.error("Error handling SES delivery notification", error); + if (!event) { return; } + + await sendWebhookEvent({ + organizationId: message.organizationId.toString(), + inboxId: message.inboxId.toString(), + messageId: message.id, + event, + payload: message, + }); } diff --git a/api/controllers/ThreadController.ts b/api/controllers/ThreadController.ts new file mode 100644 index 0000000..3e9d414 --- /dev/null +++ b/api/controllers/ThreadController.ts @@ -0,0 +1,43 @@ +import mongoose from "mongoose"; +import Thread from "../db/mongo/schemas/Thread"; + +export async function createThread({ + organizationId, + inboxId, +}: { + organizationId: string; + inboxId: string; +}) { + const thread = new Thread(); + thread.organizationId = new mongoose.Types.ObjectId(organizationId); + thread.inboxId = new mongoose.Types.ObjectId(inboxId); + thread.messages = new mongoose.Types.Array(); + await thread.save(); + return thread; +} + +export async function addMessageToThread({ + threadId, + messageId, +}: { + threadId: string; + messageId: string; +}) { + const thread = await Thread.findById(threadId); + if (!thread) { + return null; + } + thread.messages.push(new mongoose.Types.ObjectId(messageId)); + await thread.save(); + return thread; +} + +export async function getThreadsByInboxId(inboxId: string) { + const threads = await Thread.find({ inboxId: new mongoose.Types.ObjectId(inboxId) }); + return threads; +} + +export async function getThreadById(threadId: string) { + const thread = await Thread.findById(threadId).populate("messages"); + return thread; +} diff --git a/api/db/mongo/schemas/Message.ts b/api/db/mongo/schemas/Message.ts index bfe4fbb..c9b1e4f 100644 --- a/api/db/mongo/schemas/Message.ts +++ b/api/db/mongo/schemas/Message.ts @@ -14,11 +14,11 @@ const messageSchema = new mongoose.Schema( ref: "Inbox", required: true, }, - // threadId: { - // type: mongoose.Schema.Types.ObjectId, - // ref: "Thread", - // required: true, - // }, + threadId: { + type: mongoose.Schema.Types.ObjectId, + ref: "Thread", + required: true, + }, fromInboxId: { type: mongoose.Schema.Types.ObjectId, ref: "Inbox", @@ -33,6 +33,10 @@ const messageSchema = new mongoose.Schema( ref: "Inbox", required: false, }, + externalMessageId: { + type: String, + required: false, + }, to: { type: String, required: true, diff --git a/api/db/mongo/schemas/Thread.ts b/api/db/mongo/schemas/Thread.ts index f7bc05b..b58d6e5 100644 --- a/api/db/mongo/schemas/Thread.ts +++ b/api/db/mongo/schemas/Thread.ts @@ -1,12 +1,8 @@ import mongoose from "mongoose"; -import type IMessage from "../../../models/Message"; +import type IThread from "../../../models/Thread"; -const messageSchema = new mongoose.Schema( +const threadSchema = new mongoose.Schema( { - name: { - type: String, - required: true, - }, organizationId: { type: mongoose.Schema.Types.ObjectId, ref: "Organization", @@ -28,4 +24,4 @@ const messageSchema = new mongoose.Schema( } ); -export default mongoose.model("Message", messageSchema); +export default mongoose.model("Thread", threadSchema); diff --git a/api/index.ts b/api/index.ts index 7fb980c..93670ff 100644 --- a/api/index.ts +++ b/api/index.ts @@ -7,7 +7,7 @@ import cors from "cors"; import authRouter from "./routes/auth"; import organizationsRouter from "./routes/organizations"; import webhooksRouter from "./routes/webhooks"; -import inboxesRouter from "./routes/inboxes"; +import v1Router from "./routes/v1/index"; startMongo(); @@ -47,8 +47,8 @@ app.get("/health", (req, res) => { app.use("/auth", authRouter); app.use("/organizations", organizationsRouter); -app.use("/inboxes", inboxesRouter); app.use("/webhooks", webhooksRouter); +app.use("/v1", v1Router); app.listen(port, () => { console.log(`Listening on port ${port}...`); diff --git a/api/models/Message.ts b/api/models/Message.ts index 9c2c485..31a7c6e 100644 --- a/api/models/Message.ts +++ b/api/models/Message.ts @@ -2,6 +2,7 @@ import mongoose from "mongoose"; export const MessageStatus = [ "sent", + "received", "delivered", "bounced", "complained", @@ -12,10 +13,11 @@ export default interface Message { id: string; organizationId: mongoose.Types.ObjectId; inboxId: mongoose.Types.ObjectId; - // threadId: mongoose.Types.ObjectId; + threadId: mongoose.Types.ObjectId; fromInboxId?: mongoose.Types.ObjectId; from: string; toInboxId?: mongoose.Types.ObjectId; + externalMessageId?: string; to: string; subject: string; text: string; diff --git a/api/models/SES.ts b/api/models/SES.ts index 326b4a4..f948243 100644 --- a/api/models/SES.ts +++ b/api/models/SES.ts @@ -99,4 +99,5 @@ export type SNSMessage = { reject?: any; failure?: any; deliveryDelay?: any; + content: string; }; diff --git a/api/models/Webhook.ts b/api/models/Webhook.ts index ce556f6..179896c 100644 --- a/api/models/Webhook.ts +++ b/api/models/Webhook.ts @@ -5,6 +5,7 @@ export const WebhookEvents = [ "inbox.deleted", "inbox.updated", "message.sent", + "message.received", "message.delivered", "message.bounced", "message.complained", diff --git a/api/package.json b/api/package.json index 5b9f58a..8079acc 100644 --- a/api/package.json +++ b/api/package.json @@ -7,6 +7,8 @@ "@types/bun": "latest", "@types/cookie-parser": "^1.4.10", "@types/cors": "^2.8.19", + "@types/email-reply-parser": "^1.4.2", + "@types/mailparser": "^3.4.6", "@types/passport": "^1.0.17", "@types/passport-http-bearer": "^1.0.42" }, @@ -22,8 +24,10 @@ "cors": "^2.8.5", "crypto": "^1.0.1", "dotenv": "^17.2.3", + "email-reply-parser": "^2.1.0", "express": "^5.1.0", "express-validator": "^7.3.0", + "mailparser": "^3.9.0", "mongoose": "^8.19.2", "passport": "^0.7.0", "passport-http-bearer": "^1.0.1" diff --git a/api/routes/inboxes.ts b/api/routes/inboxes.ts deleted file mode 100644 index 26ec847..0000000 --- a/api/routes/inboxes.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { Router } from "express"; -import passport from "passport"; -import type { Request, Response } from "express"; -import { body } from "express-validator"; -import type { HydratedDocument } from "mongoose"; -import type Organization from "../models/Organization"; -import { expressValidatorMiddleware } from "../middlewares/expressValidatorMiddleware"; -import { createInbox, getNewRandomInboxEmail } from "../controllers/InboxController"; -import { sendWebhookEvent } from "../controllers/WebhookAttemptController"; - -const router = Router({ mergeParams: true }); - -// router.get( -// "/", -// passport.authenticate("api_key", { session: false }), -// async (req: Request<{ organizationId: string }, {}, {}>, res: Response) => { -// const organization = await getOrganizationById(req.params.organizationId!); -// if (!organization) { -// return res.status(404).json({ error: "Organization not found" }); -// } - -// const inboxes = await getInboxesByOrganizationId( -// req.params.organizationId! -// ); -// return res.json(inboxes); -// } -// ); - -router.post( - "/", - body("name").isString().notEmpty().trim(), - expressValidatorMiddleware, - passport.authenticate("api_key", { session: false }), - async ( - req: Request<{ organizationId: string }, {}, { name: string }>, - res: Response - ) => { - const organization = req.user as HydratedDocument; - const inbox = await createInbox({ - organization_id: organization.id, - name: req.body.name, - email: await getNewRandomInboxEmail({ name: req.body.name }), - }); - - await sendWebhookEvent({ - organizationId: organization.id, - event: "inbox.created", - inboxId: inbox.id, - payload: inbox, - }); - - return res.json(inbox); - } -); - -// router.use("/:inboxId/messages", messageRouter); - -export default router; diff --git a/api/routes/organizations.ts b/api/routes/organizations.ts index 6b54bce..22aee8d 100644 --- a/api/routes/organizations.ts +++ b/api/routes/organizations.ts @@ -1,10 +1,8 @@ import { Router } from "express"; import apiKeyRouter from "./organizations/apiKeys"; -import inboxRouter from "./organizations/inboxes"; const router = Router(); router.use("/:organizationId/apiKey", apiKeyRouter); -router.use("/:organizationId/inbox", inboxRouter); export default router; diff --git a/api/routes/organizations/inboxes.ts b/api/routes/organizations/inboxes.ts deleted file mode 100644 index 748d3d2..0000000 --- a/api/routes/organizations/inboxes.ts +++ /dev/null @@ -1,68 +0,0 @@ -import { Router } from "express"; -import passport from "passport"; -import type { Request, Response } from "express"; -import { getOrganizationById } from "../../controllers/OrganizationController"; -import { - createInbox, - getNewRandomInboxEmail, - getInboxesByOrganizationId, -} from "../../controllers/InboxController"; -import { body } from "express-validator"; -import { expressValidatorMiddleware } from "../../middlewares/expressValidatorMiddleware"; -import { sendWebhookEvent } from "../../controllers/WebhookAttemptController"; -import messageRouter from "./inboxes/messages"; - -const router = Router({ mergeParams: true }); - -router.get( - "/", - passport.authenticate("api_key", { session: false }), - async (req: Request<{ organizationId: string }, {}, {}>, res: Response) => { - const organization = await getOrganizationById(req.params.organizationId!); - if (!organization) { - return res.status(404).json({ error: "Organization not found" }); - } - - const inboxes = await getInboxesByOrganizationId( - req.params.organizationId! - ); - return res.json(inboxes); - } -); - -router.post( - "/", - body("name").isString().notEmpty().trim(), - expressValidatorMiddleware, - passport.authenticate("api_key", { session: false }), - async ( - req: Request<{ organizationId: string }, {}, { name: string }>, - res: Response - ) => { - const organization = await getOrganizationById( - req.params.organizationId! - ); - if (!organization) { - return res.status(404).json({ error: "Organization not found" }); - } - - const inbox = await createInbox({ - organization_id: req.params.organizationId!, - name: req.body.name, - email: await getNewRandomInboxEmail({ name: req.body.name }), - }); - - await sendWebhookEvent({ - organizationId: req.params.organizationId, - event: "inbox.created", - inboxId: inbox.id, - payload: inbox, - }); - - return res.json(inbox); - } -); - -router.use("/:inboxId/messages", messageRouter); - -export default router; diff --git a/api/routes/organizations/inboxes/messages.ts b/api/routes/organizations/inboxes/messages.ts deleted file mode 100644 index 7627f22..0000000 --- a/api/routes/organizations/inboxes/messages.ts +++ /dev/null @@ -1,73 +0,0 @@ -import { Router } from "express"; -import passport from "passport"; -import type { Request, Response } from "express"; -import { getOrganizationById } from "../../../controllers/OrganizationController"; -import { getInboxByOrganizationIdAndInboxId } from "../../../controllers/InboxController"; -import { createMessage } from "../../../controllers/MessageController"; -import { sendWebhookEvent } from "../../../controllers/WebhookAttemptController"; -import { sendSESMessage } from "../../../controllers/SESController"; - -const router = Router({ mergeParams: true }); - -router.post( - "/", - passport.authenticate("api_key", { session: false }), - async ( - req: Request< - { organizationId: string; inboxId: string }, - {}, - { - to: string; - subject: string; - text: string; - html: string; - } - >, - res: Response - ) => { - const organization = await getOrganizationById(req.params.organizationId!); - if (!organization) { - return res.status(404).json({ error: "Organization not found" }); - } - - const inbox = await getInboxByOrganizationIdAndInboxId({ - organizationId: req.params.organizationId!, - inboxId: req.params.inboxId!, - }); - if (!inbox) { - return res.status(404).json({ error: "Inbox not found" }); - } - - const message = await createMessage({ - organizationId: req.params.organizationId, - inboxId: req.params.inboxId, - fromInboxId: req.params.inboxId, - from: inbox.email, - to: req.body.to, - subject: req.body.subject, - text: req.body.text, - html: req.body.html, - }); - - sendSESMessage({ - messageId: message.id, - from: inbox.email, - fromName: inbox.name, - to: req.body.to, - subject: req.body.subject, - text: req.body.text, - html: req.body.html, - }); - - await sendWebhookEvent({ - organizationId: req.params.organizationId, - event: "message.sent", - messageId: message.id, - payload: message, - }); - - return res.json(message); - } -); - -export default router; diff --git a/api/routes/v1/inboxes/index.ts b/api/routes/v1/inboxes/index.ts new file mode 100644 index 0000000..f54e028 --- /dev/null +++ b/api/routes/v1/inboxes/index.ts @@ -0,0 +1,72 @@ +import { Router } from "express"; +import passport from "passport"; +import type { Request, Response } from "express"; +import { body } from "express-validator"; +import type { HydratedDocument } from "mongoose"; +import type Organization from "../../../models/Organization"; +import { expressValidatorMiddleware } from "../../../middlewares/expressValidatorMiddleware"; +import { createInbox, getInboxByOrganizationIdAndInboxId, getInboxesByOrganizationId } from "../../../controllers/InboxController"; +import { sendWebhookEvent } from "../../../controllers/WebhookAttemptController"; +import messagesRouter from "./messages"; +import threadsRouter from "./threads"; + +const router = Router({ mergeParams: true }); + +router.get( + "/", + passport.authenticate("api_key", { session: false }), + async (req: Request<{ organizationId: string }, {}, {}>, res: Response) => { + const organization = req.user as HydratedDocument; + const inboxes = await getInboxesByOrganizationId( + organization._id.toString() + ); + return res.json(inboxes); + } +); + +router.post( + "/", + body("name").isString().notEmpty().trim(), + expressValidatorMiddleware, + passport.authenticate("api_key", { session: false }), + async ( + req: Request<{ organizationId: string }, {}, { name: string }>, + res: Response + ) => { + const organization = req.user as HydratedDocument; + const inbox = await createInbox({ + organization_id: organization._id.toString(), + name: req.body.name, + }); + + await sendWebhookEvent({ + organizationId: organization._id.toString(), + event: "inbox.created", + inboxId: inbox.id, + payload: inbox, + }); + + return res.json(inbox); + } +); + +router.get( + "/:inboxId", + passport.authenticate("api_key", { session: false }), + async (req: Request<{ organizationId: string, inboxId: string }, {}, {}>, res: Response) => { + const organization = req.user as HydratedDocument; + const inbox = await getInboxByOrganizationIdAndInboxId({ + organizationId: organization._id.toString(), + inboxId: req.params.inboxId, + }); + if (!inbox) { + return res.status(404).json({ error: "Inbox not found" }); + } + return res.json(inbox); + } +); + +router.use("/:inboxId/messages", messagesRouter); +router.use("/:inboxId/threads", threadsRouter); + +export default router; diff --git a/api/routes/v1/inboxes/messages.ts b/api/routes/v1/inboxes/messages.ts new file mode 100644 index 0000000..9df24a5 --- /dev/null +++ b/api/routes/v1/inboxes/messages.ts @@ -0,0 +1,214 @@ +import { Router } from "express"; +import passport from "passport"; +import type { Request, Response } from "express"; +import { getInboxByOrganizationIdAndInboxId } from "../../../controllers/InboxController"; +import { + createMessage, + getMessageById, + getMessagesByInboxId, +} from "../../../controllers/MessageController"; +import { sendWebhookEvent } from "../../../controllers/WebhookAttemptController"; +import { sendSESMessage } from "../../../controllers/SESController"; +import type { HydratedDocument } from "mongoose"; +import type Organization from "../../../models/Organization"; +import { + addMessageToThread, + createThread, +} from "../../../controllers/ThreadController"; + +const router = Router({ mergeParams: true }); + +router.post( + "/send", + passport.authenticate("api_key", { session: false }), + async ( + req: Request< + { organizationId: string; inboxId: string }, + {}, + { + to: string; + subject: string; + text: string; + html: string; + } + >, + res: Response + ) => { + const organization = req.user as HydratedDocument; + + const inbox = await getInboxByOrganizationIdAndInboxId({ + organizationId: organization._id.toString(), + inboxId: req.params.inboxId!, + }); + if (!inbox) { + return res.status(404).json({ error: "Inbox not found" }); + } + + const thread = await createThread({ + organizationId: organization._id.toString(), + inboxId: req.params.inboxId, + }); + + const message = await createMessage({ + organizationId: organization._id.toString(), + inboxId: req.params.inboxId, + threadId: thread.id, + fromInboxId: req.params.inboxId, + from: inbox.email, + to: req.body.to, + subject: req.body.subject, + text: req.body.text, + html: req.body.html, + }); + + const sesMessage = await sendSESMessage({ + messageId: message.id, + from: inbox.email, + fromName: inbox.name, + to: req.body.to, + subject: req.body.subject, + text: req.body.text, + html: req.body.html, + }); + + if (sesMessage.MessageId) { + message.externalMessageId = sesMessage.MessageId; + await message.save(); + } + + await addMessageToThread({ + threadId: thread._id.toString(), + messageId: message._id.toString(), + }); + + await sendWebhookEvent({ + organizationId: organization._id.toString(), + event: "message.sent", + messageId: message.id, + payload: message, + }); + + return res.json(message); + } +); + +router.get( + "/", + passport.authenticate("api_key", { session: false }), + async ( + req: Request<{ organizationId: string; inboxId: string }>, + res: Response + ) => { + const organization = req.user as HydratedDocument; + + const inbox = await getInboxByOrganizationIdAndInboxId({ + organizationId: organization._id.toString(), + inboxId: req.params.inboxId!, + }); + if (!inbox) { + return res.status(404).json({ error: "Inbox not found" }); + } + + const messages = await getMessagesByInboxId(req.params.inboxId); + return res.json(messages); + } +); + +router.get( + "/:messageId", + passport.authenticate("api_key", { session: false }), + async ( + req: Request<{ + organizationId: string; + inboxId: string; + messageId: string; + }>, + res: Response + ) => { + const organization = req.user as HydratedDocument; + + const message = await getMessageById(req.params.messageId); + if ( + !message || + message.organizationId.toString() !== organization._id.toString() + ) { + return res.status(404).json({ error: "Message not found" }); + } + + return res.json(message); + } +); + +router.post( + "/:messageId/reply", + passport.authenticate("api_key", { session: false }), + async ( + req: Request< + { organizationId: string; inboxId: string; messageId: string }, + {}, + { text: string; html: string } + >, + res: Response + ) => { + const organization = req.user as HydratedDocument; + + const inbox = await getInboxByOrganizationIdAndInboxId({ + organizationId: organization._id.toString(), + inboxId: req.params.inboxId!, + }); + if (!inbox) { + return res.status(404).json({ error: "Inbox not found" }); + } + + const replyToMessage = await getMessageById(req.params.messageId); + if ( + !replyToMessage || + replyToMessage.organizationId.toString() !== organization._id.toString() + ) { + return res.status(404).json({ error: "Message not found" }); + } + + const message = await createMessage({ + organizationId: organization._id.toString(), + inboxId: req.params.inboxId, + threadId: replyToMessage.threadId.toString(), + fromInboxId: req.params.inboxId, + from: inbox.email, + to: replyToMessage.from, + subject: `Re: ${replyToMessage.subject}`, + text: req.body.text, + html: req.body.html, + }); + + const sesMessage = await sendSESMessage({ + messageId: message._id.toString(), + from: inbox.email, + fromName: inbox.name, + to: message.to, + subject: message.subject, + text: message.text, + html: message.html, + }); + + if (sesMessage.MessageId) { + message.externalMessageId = sesMessage.MessageId; + await message.save(); + } + + await addMessageToThread({ + threadId: replyToMessage.threadId.toString(), + messageId: message._id.toString(), + }); + + await sendWebhookEvent({ + organizationId: organization._id.toString(), + event: "message.sent", + messageId: message.id, + payload: message, + }); + + return res.json(message); + } +); + +export default router; diff --git a/api/routes/v1/inboxes/threads.ts b/api/routes/v1/inboxes/threads.ts new file mode 100644 index 0000000..4dd1a41 --- /dev/null +++ b/api/routes/v1/inboxes/threads.ts @@ -0,0 +1,58 @@ +import { Router } from "express"; +import passport from "passport"; +import type { Request, Response } from "express"; +import { getInboxByOrganizationIdAndInboxId } from "../../../controllers/InboxController"; +import type { HydratedDocument } from "mongoose"; +import type Organization from "../../../models/Organization"; +import { + getThreadById, + getThreadsByInboxId, +} from "../../../controllers/ThreadController"; + +const router = Router({ mergeParams: true }); + +router.get( + "/", + passport.authenticate("api_key", { session: false }), + async ( + req: Request<{ organizationId: string; inboxId: string }>, + res: Response + ) => { + const organization = req.user as HydratedDocument; + + const inbox = await getInboxByOrganizationIdAndInboxId({ + organizationId: organization._id.toString(), + inboxId: req.params.inboxId!, + }); + if (!inbox) { + return res.status(404).json({ error: "Inbox not found" }); + } + + const threads = await getThreadsByInboxId(req.params.inboxId); + return res.json(threads); + } +); + +router.get( + "/:threadId", + passport.authenticate("api_key", { session: false }), + async ( + req: Request<{ organizationId: string; inboxId: string; threadId: string }>, + res: Response + ) => { + const organization = req.user as HydratedDocument; + + const thread = await getThreadById(req.params.threadId); + if ( + !thread || + thread.organizationId.toString() !== organization._id.toString() || + thread.inboxId.toString() !== req.params.inboxId + ) { + return res.status(404).json({ error: "Thread not found" }); + } + + return res.json(thread); + } +); + +export default router; diff --git a/api/routes/v1/index.ts b/api/routes/v1/index.ts new file mode 100644 index 0000000..cfc45cf --- /dev/null +++ b/api/routes/v1/index.ts @@ -0,0 +1,8 @@ +import { Router } from "express"; +import inboxesRouter from "./inboxes"; + +const router = Router({ mergeParams: true }); + +router.use("/inboxes", inboxesRouter); + +export default router; \ No newline at end of file diff --git a/api/routes/webhooks/ses.ts b/api/routes/webhooks/ses.ts index 19be6ef..a73ebe8 100644 --- a/api/routes/webhooks/ses.ts +++ b/api/routes/webhooks/ses.ts @@ -4,10 +4,8 @@ import { handleDeliveryNotification } from "../../controllers/SESController"; const router = Router(); router.post("/", async (req, res) => { - console.log("Received SES delivery notification", req.body); const payload = JSON.parse(req.body); if (payload.Type !== "Notification") { - console.log("Received non-notification message from SES", payload); return res.status(200).send(); } await handleDeliveryNotification(payload.Message); diff --git a/api/tests/InboxController.spec.ts b/api/tests/InboxController.spec.ts new file mode 100644 index 0000000..87c92ce --- /dev/null +++ b/api/tests/InboxController.spec.ts @@ -0,0 +1,36 @@ +import dotenv from "dotenv"; +dotenv.config(); +// @ts-ignore +import { expect, beforeAll, afterAll, describe, it } from "bun:test"; +import mongoose from "mongoose"; +import { faker } from "@faker-js/faker"; +import { register } from "../controllers/AuthController"; +import { createInbox } from "../controllers/InboxController"; + +describe("InboxController", function () { + beforeAll(async function () { + await mongoose.connect(process.env.MONGO_URI); + }); + afterAll(async function () { + await mongoose.disconnect(); + }); + describe("createInbox", function () { + it("should create an inbox with a random email", async function () { + const { organization } = await register( + faker.person.firstName(), + faker.person.lastName(), + faker.internet.email(), + faker.internet.password() + ); + const name = faker.person.fullName(); + const inbox = await createInbox({ + organization_id: organization.id, + name, + }); + expect(inbox).toBeDefined(); + expect(inbox.organizationId.toString()).toBe(organization.id); + expect(inbox.name).toBe(name); + expect(inbox.email).toBeDefined(); + }); + }); +}); diff --git a/api/tests/MessageController.spec.ts b/api/tests/MessageController.spec.ts index eb36294..a8e6150 100644 --- a/api/tests/MessageController.spec.ts +++ b/api/tests/MessageController.spec.ts @@ -8,6 +8,7 @@ import { register } from "../controllers/AuthController"; import { createMessage } from "../controllers/MessageController"; import { createInbox, getNewRandomInboxEmail } from "../controllers/InboxController"; import { sendSESMessage } from "../controllers/SESController"; +import { createThread } from "../controllers/ThreadController"; describe("MessageController", function () { beforeAll(async function () { @@ -26,11 +27,13 @@ describe("MessageController", function () { ); const toEmail = "marc@rupt.dev"; const name = faker.person.fullName(); - console.log("name", name); const inbox = await createInbox({ organization_id: organization.id, name, - email: await getNewRandomInboxEmail({ name }) + }); + const thread = await createThread({ + organizationId: organization.id, + inboxId: inbox.id, }); const subject = faker.lorem.sentence(); const text = faker.lorem.paragraph(); @@ -38,6 +41,7 @@ describe("MessageController", function () { const message = await createMessage({ organizationId: organization.id, inboxId: inbox.id, + threadId: thread.id, fromInboxId: inbox.id, from: inbox.email, to: toEmail, diff --git a/node-sdk/index.ts b/node-sdk/index.ts index af96fed..e863872 100644 --- a/node-sdk/index.ts +++ b/node-sdk/index.ts @@ -6,26 +6,126 @@ class SendookAPI { constructor(apiSecret: string, apiUrl?: string) { this.apiSecret = apiSecret; - this.apiUrl = apiUrl || "https://api.sendook.com/v1"; + this.apiUrl = apiUrl || "https://api.sendook.com"; } - async createInbox({ - name, - email, - }: { - name: string; - email?: string; - }) { - const response = await axios.post(`${this.apiUrl}/inboxes`, { + public inbox = { + create: async ({ name, email, - }, { - headers: { - "Authorization": `Bearer ${this.apiSecret}`, + }: { + name: string; + email?: string; + }) => { + const response = await axios.post(`${this.apiUrl}/v1/inboxes`, { + name, + email, + }, { + headers: { + "Authorization": `Bearer ${this.apiSecret}`, + }, + }); + return response.data; + }, + list: async () => { + const response = await axios.get(`${this.apiUrl}/v1/inboxes`, { + headers: { + "Authorization": `Bearer ${this.apiSecret}`, + }, + }); + return response.data; + }, + get: async (inboxId: string) => { + const response = await axios.get(`${this.apiUrl}/v1/inboxes/${inboxId}`, { + headers: { + "Authorization": `Bearer ${this.apiSecret}`, + }, + }); + return response.data; + }, + message: { + send: async ({ + inboxId, + to, + subject, + text, + html, + }: { + inboxId: string; + to: string; + subject: string; + text: string; + html: string; + }) => { + const response = await axios.post(`${this.apiUrl}/v1/inboxes/${inboxId}/messages/send`, { + to, + subject, + text, + html, + }, { + headers: { + "Authorization": `Bearer ${this.apiSecret}`, + }, + }); + return response.data; }, - }); - return response.data; - } + reply: async ({ + inboxId, + messageId, + text, + html, + }: { + inboxId: string; + messageId: string; + text: string; + html: string; + }) => { + const response = await axios.post(`${this.apiUrl}/v1/inboxes/${inboxId}/messages/${messageId}/reply`, { + text, + html, + }, { + headers: { + "Authorization": `Bearer ${this.apiSecret}`, + }, + }); + return response.data; + }, + list: async (inboxId: string) => { + const response = await axios.get(`${this.apiUrl}/v1/inboxes/${inboxId}/messages`, { + headers: { + "Authorization": `Bearer ${this.apiSecret}`, + }, + }); + return response.data; + }, + get: async (inboxId: string, messageId: string) => { + const response = await axios.get(`${this.apiUrl}/v1/inboxes/${inboxId}/messages/${messageId}`, { + headers: { + "Authorization": `Bearer ${this.apiSecret}`, + }, + }); + return response.data; + }, + }, + thread: { + list: async (inboxId: string) => { + const response = await axios.get(`${this.apiUrl}/v1/inboxes/${inboxId}/threads`, { + headers: { + "Authorization": `Bearer ${this.apiSecret}`, + }, + }); + return response.data; + }, + get: async (inboxId: string, threadId: string) => { + const response = await axios.get(`${this.apiUrl}/v1/inboxes/${inboxId}/threads/${threadId}`, { + headers: { + "Authorization": `Bearer ${this.apiSecret}`, + }, + }); + return response.data; + }, + }, + }; } export default SendookAPI; diff --git a/node-sdk/tests/InboxAPI.spec.ts b/node-sdk/tests/InboxAPI.spec.ts new file mode 100644 index 0000000..e87429f --- /dev/null +++ b/node-sdk/tests/InboxAPI.spec.ts @@ -0,0 +1,43 @@ +import dotenv from "dotenv"; +import { faker } from "@faker-js/faker"; +dotenv.config(); +// @ts-ignore +import { expect, describe, it } from "bun:test"; +import Sendook from "../index"; + +describe("InboxAPI", function () { + describe("createInbox", function () { + it("should create an inbox", async function () { + const sendook = new Sendook(process.env.API_KEY, process.env.API_URL); + const name = faker.person.fullName(); + const inbox = await sendook.inbox.create({ + name, + }); + expect(inbox).toBeDefined(); + expect(inbox.name).toBe(name); + expect(inbox.email).toBeDefined(); + }); + }); + describe("getInboxes", function () { + it("should get inboxes", async function () { + const sendook = new Sendook(process.env.API_KEY, process.env.API_URL); + const inboxes = await sendook.inbox.list(); + expect(inboxes).toBeDefined(); + expect(inboxes.length).toBeGreaterThan(0); + }); + }); + describe("getInbox", function () { + it("should get an inbox", async function () { + const sendook = new Sendook(process.env.API_KEY, process.env.API_URL); + const name = faker.person.fullName(); + const newInbox = await sendook.inbox.create({ + name, + }); + const inbox = await sendook.inbox.get(newInbox.id); + expect(inbox).toBeDefined(); + expect(inbox.id).toBe(newInbox.id); + expect(inbox.name).toBe(name); + expect(inbox.email).toBeDefined(); + }); + }); +}); diff --git a/node-sdk/tests/InboxController.spec.ts b/node-sdk/tests/InboxController.spec.ts deleted file mode 100644 index 3c3b237..0000000 --- a/node-sdk/tests/InboxController.spec.ts +++ /dev/null @@ -1,21 +0,0 @@ -import dotenv from "dotenv"; -import { faker } from "@faker-js/faker"; -dotenv.config(); -// @ts-ignore -import { expect, describe, it } from "bun:test"; -import Sendook from "../index"; - -describe("SendookAPI", function () { - describe("createInbox", function () { - it("should create an inbox", async function () { - const sendook = new Sendook(process.env.API_KEY, process.env.API_URL); - const name = faker.person.fullName(); - const inbox = await sendook.createInbox({ - name, - }); - expect(inbox).toBeDefined(); - expect(inbox.name).toBe(name); - expect(inbox.email).toBeDefined(); - }); - }); -}); diff --git a/node-sdk/tests/messageAPI.spec.ts b/node-sdk/tests/messageAPI.spec.ts new file mode 100644 index 0000000..df2d7d4 --- /dev/null +++ b/node-sdk/tests/messageAPI.spec.ts @@ -0,0 +1,90 @@ +import dotenv from "dotenv"; +import { faker } from "@faker-js/faker"; +dotenv.config(); +// @ts-ignore +import { expect, describe, it } from "bun:test"; +import Sendook from "../index"; + +describe("MessageAPI", function () { + describe("sendMessage", function () { + it("should send a message", async function () { + const sendook = new Sendook(process.env.API_KEY, process.env.API_URL); + const name = faker.person.fullName(); + const newInbox = await sendook.inbox.create({ + name, + }); + const message = await sendook.inbox.message.send({ + inboxId: newInbox._id, + to: "marc@rupt.dev", + subject: "Test Subject", + text: "Test Text", + html: "

Test HTML

", + }); + expect(message).toBeDefined(); + }); + }); + describe("listMessages", function () { + it("should list messages", async function () { + const sendook = new Sendook(process.env.API_KEY, process.env.API_URL); + const name = faker.person.fullName(); + const newInbox = await sendook.inbox.create({ + name, + }); + const newMessage = await sendook.inbox.message.send({ + inboxId: newInbox._id, + to: "marc@rupt.dev", + subject: "Test Subject", + text: "Test Text", + html: "

Test HTML

", + }); + const messages = await sendook.inbox.message.list(newInbox._id); + expect(messages).toBeDefined(); + expect(messages.length).toBeGreaterThan(0); + }); + }); + describe("getMessage", function () { + it("should get a message", async function () { + const sendook = new Sendook(process.env.API_KEY, process.env.API_URL); + const name = faker.person.fullName(); + const newInbox = await sendook.inbox.create({ + name, + }); + const newMessage = await sendook.inbox.message.send({ + inboxId: newInbox._id, + to: "marc@rupt.dev", + subject: "Test Subject", + text: "Test Text", + html: "

Test HTML

", + }); + const message = await sendook.inbox.message.get(newInbox._id, newMessage._id); + expect(message).toBeDefined(); + expect(message._id).toBe(newMessage._id); + }); + }); + describe("replyToMessage", function () { + it("should reply to a message", async function () { + const sendook = new Sendook(process.env.API_KEY, process.env.API_URL); + const name = faker.person.fullName(); + const newInbox = await sendook.inbox.create({ + name, + }); + const newMessage = await sendook.inbox.message.send({ + inboxId: newInbox._id, + to: "marc@rupt.dev", + subject: "Test Subject", + text: "Test Text", + html: "

Test HTML

", + }); + const replyMessage = await sendook.inbox.message.reply({ + inboxId: newInbox._id, + messageId: newMessage._id, + text: "Test Reply Text", + html: "

Test Reply HTML

", + }); + expect(replyMessage).toBeDefined(); + expect(replyMessage.subject).toBe(`Re: ${newMessage.subject}`); + expect(replyMessage.text).toBe("Test Reply Text"); + expect(replyMessage.html).toBe("

Test Reply HTML

"); + }); + }); +}); diff --git a/node-sdk/tests/threadAPI.spec.ts b/node-sdk/tests/threadAPI.spec.ts new file mode 100644 index 0000000..7ee9b6d --- /dev/null +++ b/node-sdk/tests/threadAPI.spec.ts @@ -0,0 +1,48 @@ +import dotenv from "dotenv"; +import { faker } from "@faker-js/faker"; +dotenv.config(); +// @ts-ignore +import { expect, describe, it } from "bun:test"; +import Sendook from "../index"; + +describe("ThreadAPI", function () { + describe("listThreads", function () { + it("should list threads", async function () { + const sendook = new Sendook(process.env.API_KEY, process.env.API_URL); + const name = faker.person.fullName(); + const newInbox = await sendook.inbox.create({ + name, + }); + const newMessage = await sendook.inbox.message.send({ + inboxId: newInbox._id, + to: "marc@rupt.dev", + subject: "Test Subject", + text: "Test Text", + html: "

Test HTML

", + }); + const threads = await sendook.inbox.thread.list(newInbox._id); + expect(threads).toBeDefined(); + expect(threads.length).toBeGreaterThan(0); + }); + }); + describe("getThread", function () { + it("should get a thread", async function () { + const sendook = new Sendook(process.env.API_KEY, process.env.API_URL); + const name = faker.person.fullName(); + const newInbox = await sendook.inbox.create({ + name, + }); + const newMessage = await sendook.inbox.message.send({ + inboxId: newInbox._id, + to: "marc@rupt.dev", + subject: "Test Subject", + text: "Test Text", + html: "

Test HTML

", + }); + const thread = await sendook.inbox.thread.get(newInbox._id, newMessage.threadId); + expect(thread).toBeDefined(); + expect(thread._id).toBe(newMessage.threadId); + expect(thread.messages.length).toBeGreaterThan(0); + }); + }); +});