initial version - hackathon
This commit is contained in:
commit
56bc5b62a6
11 changed files with 1928 additions and 0 deletions
54
README.md
Normal file
54
README.md
Normal file
|
@ -0,0 +1,54 @@
|
|||
## Inspiration
|
||||
|
||||
Email was once a great decentralized protocol, but it became corrupted over time. As spammers and scammers gained ground, the solution that emerged was centralization. Internet giants took over, deciding what's spam and what isn't.
|
||||
|
||||
With Nostr, we no longer need to rely on this outdated approach, which contradicts the principles of self-sovereign, decentralized control of your own data.
|
||||
|
||||
The recent Google ban on the Bitcoin Dev mailing list for "unwanted content" was our wake-up call. Something had to be done now, and this hackathon gave us the perfect opportunity to help make email decentralized again.
|
||||
|
||||
## What it does
|
||||
|
||||
My project creates a bridge between Nostr and traditional email protocols (IMAP/SMTP). It's a proxy server that lets you use your favorite email client to interact with mailing lists built on top of Nostr.
|
||||
|
||||
Just configure this proxy server in any email client that supports proxies, and you can instantly send and receive messages through Nostr while keeping your familiar email interface. This means you can participate in decentralized, censorship-resistant mailing lists (like a new Bitcoin Dev list) without changing your workflow.
|
||||
|
||||
I recommend self-hosting the server, or if you do not, at the very least use a burnable nostr identity, as there wasn't enough time to build proper security.
|
||||
|
||||
## How we built it
|
||||
|
||||
I built a dual-protocol server that:
|
||||
1. Implements IMAP for reading messages from Nostr relays
|
||||
2. Implements SMTP for sending messages to Nostr relays
|
||||
3. Handles translation between email formats and Nostr events
|
||||
4. Maps email-specific concepts (folders, flags, etc.) to Nostr constructs
|
||||
|
||||
I used TypeScript with Deno for development.
|
||||
|
||||
## Challenges we ran into
|
||||
|
||||
- Bridging different paradigms: Email and Nostr have fundamentally different models for message storage and addressing
|
||||
- Building a compliant IMAP/SMTP server from scratch was more complex than anticipated (especially considering I had never written anything to interact with IMAP before)
|
||||
- Handling the different threading models between email and Nostr events
|
||||
- Mapping between email concepts like "folders" and Nostr filters
|
||||
|
||||
## Accomplishments that we're proud of
|
||||
|
||||
- Creating a functional bridge that lets people use familiar email tools for Nostr
|
||||
- Implementing working IMAP and SMTP servers that speak Nostr on the backend
|
||||
- Building a solution that can help keep important communities like Bitcoin Dev free from censorship
|
||||
- Demonstrating that decentralization doesn't have to mean abandoning familiar interfaces
|
||||
- Delivering a working prototype during a hackathon timeframe (especially considering I started coding this on saturday, when the hackathon was already midway through)
|
||||
|
||||
## What we learned
|
||||
|
||||
- The intricacies of the IMAP and SMTP protocols and their implementation details
|
||||
- The complexity of making new protocols accessible to existing tooling
|
||||
|
||||
## What's next for Nostr via Email
|
||||
|
||||
- Hosting a Bitcoin Dev mailing list on Nostr to ensure it's never censored again
|
||||
- Adding support for more email features (attachments, advanced filtering)
|
||||
- Browser based view
|
||||
- Implementing NIP-05 identifiers to make email addresses map cleanly to Nostr identities
|
||||
- Loading user profiles, so mail clients don't listen stray npubs
|
||||
- Letting the world build something better on top of what I wrote while I focus on building Closed Community Networks
|
16
deno.json
Normal file
16
deno.json
Normal file
|
@ -0,0 +1,16 @@
|
|||
{
|
||||
"tasks": {
|
||||
"dev": "deno run --allow-net --allow-read --allow-write --watch main.ts"
|
||||
},
|
||||
"imports": {
|
||||
"@nostr/tools": "jsr:@nostr/tools@^2.12.0",
|
||||
"@std/assert": "jsr:@std/assert@1"
|
||||
},
|
||||
"fmt": {
|
||||
"lineWidth": 120,
|
||||
"semiColons": true,
|
||||
"indentWidth": 2,
|
||||
"lineEnding": "lf",
|
||||
"useTabs": false
|
||||
}
|
||||
}
|
200
deno.lock
generated
Normal file
200
deno.lock
generated
Normal file
|
@ -0,0 +1,200 @@
|
|||
{
|
||||
"version": "4",
|
||||
"specifiers": {
|
||||
"jsr:@nostr/tools@^2.12.0": "2.12.0",
|
||||
"jsr:@std/assert@1": "1.0.12",
|
||||
"jsr:@std/fs@*": "1.0.15",
|
||||
"jsr:@std/internal@^1.0.6": "1.0.6",
|
||||
"npm:@noble/ciphers@~0.5.1": "0.5.3",
|
||||
"npm:@noble/curves@1.2.0": "1.2.0",
|
||||
"npm:@noble/hashes@1.3.1": "1.3.1",
|
||||
"npm:@scure/base@1.1.1": "1.1.1",
|
||||
"npm:@scure/bip32@1.3.1": "1.3.1",
|
||||
"npm:@scure/bip39@1.2.1": "1.2.1",
|
||||
"npm:@types/node@*": "22.12.0",
|
||||
"npm:nostr-wasm@0.1.0": "0.1.0"
|
||||
},
|
||||
"jsr": {
|
||||
"@nostr/tools@2.12.0": {
|
||||
"integrity": "0584d5197682c6eabaded17bae10e765f215ef051ae70aa463f994abf90f295a",
|
||||
"dependencies": [
|
||||
"npm:@noble/ciphers",
|
||||
"npm:@noble/curves",
|
||||
"npm:@noble/hashes",
|
||||
"npm:@scure/base",
|
||||
"npm:@scure/bip32",
|
||||
"npm:@scure/bip39",
|
||||
"npm:nostr-wasm"
|
||||
]
|
||||
},
|
||||
"@std/assert@1.0.12": {
|
||||
"integrity": "08009f0926dda9cbd8bef3a35d3b6a4b964b0ab5c3e140a4e0351fbf34af5b9a",
|
||||
"dependencies": [
|
||||
"jsr:@std/internal"
|
||||
]
|
||||
},
|
||||
"@std/fs@1.0.15": {
|
||||
"integrity": "c083fb479889d6440d768e498195c3fc499d426fbf9a6592f98f53884d1d3f41"
|
||||
},
|
||||
"@std/internal@1.0.6": {
|
||||
"integrity": "9533b128f230f73bd209408bb07a4b12f8d4255ab2a4d22a1fd6d87304aca9a4"
|
||||
}
|
||||
},
|
||||
"npm": {
|
||||
"@noble/ciphers@0.5.3": {
|
||||
"integrity": "sha512-B0+6IIHiqEs3BPMT0hcRmHvEj2QHOLu+uwt+tqDDeVd0oyVzh7BPrDcPjRnV1PV/5LaknXJJQvOuRGR0zQJz+w=="
|
||||
},
|
||||
"@noble/curves@1.1.0": {
|
||||
"integrity": "sha512-091oBExgENk/kGj3AZmtBDMpxQPDtxQABR2B9lb1JbVTs6ytdzZNwvhxQ4MWasRNEzlbEH8jCWFCwhF/Obj5AA==",
|
||||
"dependencies": [
|
||||
"@noble/hashes@1.3.1"
|
||||
]
|
||||
},
|
||||
"@noble/curves@1.2.0": {
|
||||
"integrity": "sha512-oYclrNgRaM9SsBUBVbb8M6DTV7ZHRTKugureoYEncY5c65HOmRzvSiTE3y5CYaPYJA/GVkrhXEoF0M3Ya9PMnw==",
|
||||
"dependencies": [
|
||||
"@noble/hashes@1.3.2"
|
||||
]
|
||||
},
|
||||
"@noble/hashes@1.3.1": {
|
||||
"integrity": "sha512-EbqwksQwz9xDRGfDST86whPBgM65E0OH/pCgqW0GBVzO22bNE+NuIbeTb714+IfSjU3aRk47EUvXIb5bTsenKA=="
|
||||
},
|
||||
"@noble/hashes@1.3.2": {
|
||||
"integrity": "sha512-MVC8EAQp7MvEcm30KWENFjgR+Mkmf+D189XJTkFIlwohU5hcBbn1ZkKq7KVTi2Hme3PMGF390DaL52beVrIihQ=="
|
||||
},
|
||||
"@scure/base@1.1.1": {
|
||||
"integrity": "sha512-ZxOhsSyxYwLJj3pLZCefNitxsj093tb2vq90mp2txoYeBqbcjDjqFhyM8eUjq/uFm6zJ+mUuqxlS2FkuSY1MTA=="
|
||||
},
|
||||
"@scure/bip32@1.3.1": {
|
||||
"integrity": "sha512-osvveYtyzdEVbt3OfwwXFr4P2iVBL5u1Q3q4ONBfDY/UpOuXmOlbgwc1xECEboY8wIays8Yt6onaWMUdUbfl0A==",
|
||||
"dependencies": [
|
||||
"@noble/curves@1.1.0",
|
||||
"@noble/hashes@1.3.2",
|
||||
"@scure/base"
|
||||
]
|
||||
},
|
||||
"@scure/bip39@1.2.1": {
|
||||
"integrity": "sha512-Z3/Fsz1yr904dduJD0NpiyRHhRYHdcnyh73FZWiV+/qhWi83wNJ3NWolYqCEN+ZWsUz2TWwajJggcRE9r1zUYg==",
|
||||
"dependencies": [
|
||||
"@noble/hashes@1.3.2",
|
||||
"@scure/base"
|
||||
]
|
||||
},
|
||||
"@types/node@22.12.0": {
|
||||
"integrity": "sha512-Fll2FZ1riMjNmlmJOdAyY5pUbkftXslB5DgEzlIuNaiWhXd00FhWxVC/r4yV/4wBb9JfImTu+jiSvXTkJ7F/gA==",
|
||||
"dependencies": [
|
||||
"undici-types"
|
||||
]
|
||||
},
|
||||
"nostr-wasm@0.1.0": {
|
||||
"integrity": "sha512-78BTryCLcLYv96ONU8Ws3Q1JzjlAt+43pWQhIl86xZmWeegYCNLPml7yQ+gG3vR6V5h4XGj+TxO+SS5dsThQIA=="
|
||||
},
|
||||
"undici-types@6.20.0": {
|
||||
"integrity": "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg=="
|
||||
}
|
||||
},
|
||||
"redirects": {
|
||||
"https://deno.land/std/http/file_server.ts": "https://deno.land/std@0.224.0/http/file_server.ts",
|
||||
"https://deno.land/std/http/server.ts": "https://deno.land/std@0.224.0/http/server.ts"
|
||||
},
|
||||
"remote": {
|
||||
"https://deno.land/std@0.204.0/assert/assert.ts": "9a97dad6d98c238938e7540736b826440ad8c1c1e54430ca4c4e623e585607ee",
|
||||
"https://deno.land/std@0.204.0/assert/assertion_error.ts": "4d0bde9b374dfbcbe8ac23f54f567b77024fb67dbb1906a852d67fe050d42f56",
|
||||
"https://deno.land/std@0.204.0/async/delay.ts": "a6142eb44cdd856b645086af2b811b1fcce08ec06bb7d50969e6a872ee9b8659",
|
||||
"https://deno.land/std@0.204.0/collections/_utils.ts": "5114abc026ddef71207a79609b984614e66a63a4bda17d819d56b0e72c51527e",
|
||||
"https://deno.land/std@0.204.0/collections/deep_merge.ts": "9db788ba56cb05b65c77166b789e58e125dff159b7f41bf4d19dc1cba19ecb8b",
|
||||
"https://deno.land/std@0.204.0/encoding/_util.ts": "f368920189c4fe6592ab2e93bd7ded8f3065b84f95cd3e036a4a10a75649dcba",
|
||||
"https://deno.land/std@0.204.0/encoding/base64.ts": "cc03110d6518170aeaa68ec97f89c6d6e2276294b30807e7332591d7ce2e4b72",
|
||||
"https://deno.land/std@0.204.0/flags/mod.ts": "0948466fc437f017f00c0b972a422b3dc3317a790bcf326429d23182977eaf9f",
|
||||
"https://deno.land/std@0.204.0/fmt/bytes.ts": "f29cf69e0791d375f9f5d94ae1f0641e5a03b975f32ddf86d70f70fdf37e7b6a",
|
||||
"https://deno.land/std@0.204.0/fmt/colors.ts": "c51c4642678eb690dcf5ffee5918b675bf01a33fba82acf303701ae1a4f8c8d9",
|
||||
"https://deno.land/std@0.204.0/http/etag.ts": "807382795850cde5c437c74bcc09392bc0fc56de348fc1271f383f4b28935b9f",
|
||||
"https://deno.land/std@0.204.0/http/file_server.ts": "da09f1de4f9776721b52c31826db437b2fa95f7bd9d835d19147b5e809ab6835",
|
||||
"https://deno.land/std@0.204.0/http/http_status.ts": "8a7bcfe3ac025199ad804075385e57f63d055b2aed539d943ccc277616d6f932",
|
||||
"https://deno.land/std@0.204.0/http/server.ts": "1b2403b3c544c0624ad23e8ca4e05877e65380d9e0d75d04957432d65c3d5f41",
|
||||
"https://deno.land/std@0.204.0/http/util.ts": "4cf044067febaa26d0830e356b0f3a5f76d701a60d7ff7a516fad7b192f4c3a7",
|
||||
"https://deno.land/std@0.204.0/media_types/_db.ts": "7606d83e31f23ce1a7968cbaee852810c2cf477903a095696cdc62eaab7ce570",
|
||||
"https://deno.land/std@0.204.0/media_types/_util.ts": "0879b04cc810ff18d3dcd97d361e03c9dfb29f67d7fc4a9c6c9d387282ef5fe8",
|
||||
"https://deno.land/std@0.204.0/media_types/content_type.ts": "ad98a5aa2d95f5965b2796072284258710a25e520952376ed432b0937ce743bc",
|
||||
"https://deno.land/std@0.204.0/media_types/format_media_type.ts": "f5e1073c05526a6f5a516ac5c5587a1abd043bf1039c71cde1166aa4328c8baf",
|
||||
"https://deno.land/std@0.204.0/media_types/get_charset.ts": "18b88274796fda5d353806bf409eb1d2ddb3f004eb4bd311662c4cdd8ac173db",
|
||||
"https://deno.land/std@0.204.0/media_types/parse_media_type.ts": "31ccf2388ffab31b49500bb89fa0f5de189c8897e2ee6c9954f207637d488211",
|
||||
"https://deno.land/std@0.204.0/media_types/type_by_extension.ts": "8c210d4e28ea426414dd8c61146eefbcc7e091a89ccde54bbbe883a154856afd",
|
||||
"https://deno.land/std@0.204.0/media_types/vendor/mime-db.v1.52.0.ts": "6925bbcae81ca37241e3f55908d0505724358cda3384eaea707773b2c7e99586",
|
||||
"https://deno.land/std@0.204.0/path/_common/assert_path.ts": "061e4d093d4ba5aebceb2c4da3318bfe3289e868570e9d3a8e327d91c2958946",
|
||||
"https://deno.land/std@0.204.0/path/_common/constants.ts": "e49961f6f4f48039c0dfed3c3f93e963ca3d92791c9d478ac5b43183413136e0",
|
||||
"https://deno.land/std@0.204.0/path/_common/normalize.ts": "2ba7fb4cc9fafb0f38028f434179579ce61d4d9e51296fad22b701c3d3cd7397",
|
||||
"https://deno.land/std@0.204.0/path/_common/normalize_string.ts": "88c472f28ae49525f9fe82de8c8816d93442d46a30d6bb5063b07ff8a89ff589",
|
||||
"https://deno.land/std@0.204.0/path/_common/relative.ts": "1af19d787a2a84b8c534cc487424fe101f614982ae4851382c978ab2216186b4",
|
||||
"https://deno.land/std@0.204.0/path/_os.ts": "30b0c2875f360c9296dbe6b7f2d528f0f9c741cecad2e97f803f5219e91b40a2",
|
||||
"https://deno.land/std@0.204.0/path/extname.ts": "2da4e2490f3b48b7121d19fb4c91681a5e11bd6bd99df4f6f47d7a71bb6ecdf2",
|
||||
"https://deno.land/std@0.204.0/path/join.ts": "98d3d76c819af4a11a81d5ba2dbb319f1ce9d63fc2b615597d4bcfddd4a89a09",
|
||||
"https://deno.land/std@0.204.0/path/posix/_util.ts": "ecf49560fedd7dd376c6156cc5565cad97c1abe9824f4417adebc7acc36c93e5",
|
||||
"https://deno.land/std@0.204.0/path/posix/extname.ts": "ee7f6571a9c0a37f9218fbf510c440d1685a7c13082c348d701396cc795e0be0",
|
||||
"https://deno.land/std@0.204.0/path/posix/join.ts": "0c0d84bdc344876930126640011ec1b888e6facf74153ffad9ef26813aa2a076",
|
||||
"https://deno.land/std@0.204.0/path/posix/normalize.ts": "11de90a94ab7148cc46e5a288f7d732aade1d616bc8c862f5560fa18ff987b4b",
|
||||
"https://deno.land/std@0.204.0/path/posix/relative.ts": "e2f230608b0f083e6deaa06e063943e5accb3320c28aef8d87528fbb7fe6504c",
|
||||
"https://deno.land/std@0.204.0/path/posix/resolve.ts": "51579d83159d5c719518c9ae50812a63959bbcb7561d79acbdb2c3682236e285",
|
||||
"https://deno.land/std@0.204.0/path/relative.ts": "23d45ede8b7ac464a8299663a43488aad6b561414e7cbbe4790775590db6349c",
|
||||
"https://deno.land/std@0.204.0/path/resolve.ts": "5b184efc87155a0af9fa305ff68a109e28de9aee81fc3e77cd01380f19daf867",
|
||||
"https://deno.land/std@0.204.0/path/separator.ts": "40a3e9a4ad10bef23bc2cd6c610291b6c502a06237c2c4cd034a15ca78dedc1f",
|
||||
"https://deno.land/std@0.204.0/path/windows/_util.ts": "f32b9444554c8863b9b4814025c700492a2b57ff2369d015360970a1b1099d54",
|
||||
"https://deno.land/std@0.204.0/path/windows/join.ts": "e6600bf88edeeef4e2276e155b8de1d5dec0435fd526ba2dc4d37986b2882f16",
|
||||
"https://deno.land/std@0.204.0/path/windows/normalize.ts": "9deebbf40c81ef540b7b945d4ccd7a6a2c5a5992f791e6d3377043031e164e69",
|
||||
"https://deno.land/std@0.204.0/path/windows/relative.ts": "026855cd2c36c8f28f1df3c6fbd8f2449a2aa21f48797a74700c5d872b86d649",
|
||||
"https://deno.land/std@0.204.0/path/windows/resolve.ts": "5ff441ab18a2346abadf778121128ee71bda4d0898513d4639a6ca04edca366b",
|
||||
"https://deno.land/std@0.204.0/streams/byte_slice_stream.ts": "c46d7c74836fc8c1a9acd9fe211cbe1bbaaee1b36087c834fb03af4991135c3a",
|
||||
"https://deno.land/std@0.204.0/version.ts": "cca51fbe1b22320de470caba6fb845f546d843fa5ccd1c18474bf3a683557110",
|
||||
"https://deno.land/std@0.224.0/assert/assert.ts": "09d30564c09de846855b7b071e62b5974b001bb72a4b797958fe0660e7849834",
|
||||
"https://deno.land/std@0.224.0/assert/assertion_error.ts": "ba8752bd27ebc51f723702fac2f54d3e94447598f54264a6653d6413738a8917",
|
||||
"https://deno.land/std@0.224.0/async/delay.ts": "f90dd685b97c2f142b8069082993e437b1602b8e2561134827eeb7c12b95c499",
|
||||
"https://deno.land/std@0.224.0/cli/parse_args.ts": "5250832fb7c544d9111e8a41ad272c016f5a53f975ef84d5a9fe5fcb70566ece",
|
||||
"https://deno.land/std@0.224.0/encoding/_util.ts": "beacef316c1255da9bc8e95afb1fa56ed69baef919c88dc06ae6cb7a6103d376",
|
||||
"https://deno.land/std@0.224.0/encoding/base64.ts": "dd59695391584c8ffc5a296ba82bcdba6dd8a84d41a6a539fbee8e5075286eaf",
|
||||
"https://deno.land/std@0.224.0/fmt/bytes.ts": "7b294a4b9cf0297efa55acb55d50610f3e116a0ac772d1df0ae00f0b833ccd4a",
|
||||
"https://deno.land/std@0.224.0/fmt/colors.ts": "508563c0659dd7198ba4bbf87e97f654af3c34eb56ba790260f252ad8012e1c5",
|
||||
"https://deno.land/std@0.224.0/http/etag.ts": "9ca56531be682f202e4239971931060b688ee5c362688e239eeaca39db9e72cb",
|
||||
"https://deno.land/std@0.224.0/http/file_server.ts": "2a5392195b8e7713288f274d071711b705bb5b3220294d76cce495d456c61a93",
|
||||
"https://deno.land/std@0.224.0/http/server.ts": "f9313804bf6467a1704f45f76cb6cd0a3396a3b31c316035e6a4c2035d1ea514",
|
||||
"https://deno.land/std@0.224.0/http/status.ts": "ed61b4882af2514a81aefd3245e8df4c47b9a8e54929a903577643d2d1ebf514",
|
||||
"https://deno.land/std@0.224.0/media_types/_db.ts": "19563a2491cd81b53b9c1c6ffd1a9145c355042d4a854c52f6e1424f73ff3923",
|
||||
"https://deno.land/std@0.224.0/media_types/_util.ts": "e0b8da0c7d8ad2015cf27ac16ddf0809ac984b2f3ec79f7fa4206659d4f10deb",
|
||||
"https://deno.land/std@0.224.0/media_types/content_type.ts": "ed3f2e1f243b418ad3f441edc95fd92efbadb0f9bde36219c7564c67f9639513",
|
||||
"https://deno.land/std@0.224.0/media_types/format_media_type.ts": "ffef4718afa2489530cb94021bb865a466eb02037609f7e82899c017959d288a",
|
||||
"https://deno.land/std@0.224.0/media_types/get_charset.ts": "277ebfceb205bd34e616fe6764ef03fb277b77f040706272bea8680806ae3f11",
|
||||
"https://deno.land/std@0.224.0/media_types/parse_media_type.ts": "487f000a38c230ccbac25420a50f600862e06796d0eee19d19631b9e84ee9654",
|
||||
"https://deno.land/std@0.224.0/media_types/type_by_extension.ts": "bf4e3f5d6b58b624d5daa01cbb8b1e86d9939940a77e7c26e796a075b60ec73b",
|
||||
"https://deno.land/std@0.224.0/media_types/vendor/mime-db.v1.52.0.ts": "0218d2c7d900e8cd6fa4a866e0c387712af4af9a1bae55d6b2546c73d273a1e6",
|
||||
"https://deno.land/std@0.224.0/path/_common/assert_path.ts": "dbdd757a465b690b2cc72fc5fb7698c51507dec6bfafce4ca500c46b76ff7bd8",
|
||||
"https://deno.land/std@0.224.0/path/_common/constants.ts": "dc5f8057159f4b48cd304eb3027e42f1148cf4df1fb4240774d3492b5d12ac0c",
|
||||
"https://deno.land/std@0.224.0/path/_common/normalize.ts": "684df4aa71a04bbcc346c692c8485594fc8a90b9408dfbc26ff32cf3e0c98cc8",
|
||||
"https://deno.land/std@0.224.0/path/_common/normalize_string.ts": "33edef773c2a8e242761f731adeb2bd6d683e9c69e4e3d0092985bede74f4ac3",
|
||||
"https://deno.land/std@0.224.0/path/_common/relative.ts": "faa2753d9b32320ed4ada0733261e3357c186e5705678d9dd08b97527deae607",
|
||||
"https://deno.land/std@0.224.0/path/_os.ts": "8fb9b90fb6b753bd8c77cfd8a33c2ff6c5f5bc185f50de8ca4ac6a05710b2c15",
|
||||
"https://deno.land/std@0.224.0/path/constants.ts": "0c206169ca104938ede9da48ac952de288f23343304a1c3cb6ec7625e7325f36",
|
||||
"https://deno.land/std@0.224.0/path/extname.ts": "593303db8ae8c865cbd9ceec6e55d4b9ac5410c1e276bfd3131916591b954441",
|
||||
"https://deno.land/std@0.224.0/path/join.ts": "ae2ec5ca44c7e84a235fd532e4a0116bfb1f2368b394db1c4fb75e3c0f26a33a",
|
||||
"https://deno.land/std@0.224.0/path/posix/_util.ts": "1e3937da30f080bfc99fe45d7ed23c47dd8585c5e473b2d771380d3a6937cf9d",
|
||||
"https://deno.land/std@0.224.0/path/posix/extname.ts": "e398c1d9d1908d3756a7ed94199fcd169e79466dd88feffd2f47ce0abf9d61d2",
|
||||
"https://deno.land/std@0.224.0/path/posix/join.ts": "7fc2cb3716aa1b863e990baf30b101d768db479e70b7313b4866a088db016f63",
|
||||
"https://deno.land/std@0.224.0/path/posix/normalize.ts": "baeb49816a8299f90a0237d214cef46f00ba3e95c0d2ceb74205a6a584b58a91",
|
||||
"https://deno.land/std@0.224.0/path/posix/relative.ts": "3907d6eda41f0ff723d336125a1ad4349112cd4d48f693859980314d5b9da31c",
|
||||
"https://deno.land/std@0.224.0/path/posix/resolve.ts": "08b699cfeee10cb6857ccab38fa4b2ec703b0ea33e8e69964f29d02a2d5257cf",
|
||||
"https://deno.land/std@0.224.0/path/relative.ts": "ab739d727180ed8727e34ed71d976912461d98e2b76de3d3de834c1066667add",
|
||||
"https://deno.land/std@0.224.0/path/resolve.ts": "a6f977bdb4272e79d8d0ed4333e3d71367cc3926acf15ac271f1d059c8494d8d",
|
||||
"https://deno.land/std@0.224.0/path/windows/_util.ts": "d5f47363e5293fced22c984550d5e70e98e266cc3f31769e1710511803d04808",
|
||||
"https://deno.land/std@0.224.0/path/windows/extname.ts": "165a61b00d781257fda1e9606a48c78b06815385e7d703232548dbfc95346bef",
|
||||
"https://deno.land/std@0.224.0/path/windows/join.ts": "8d03530ab89195185103b7da9dfc6327af13eabdcd44c7c63e42e27808f50ecf",
|
||||
"https://deno.land/std@0.224.0/path/windows/normalize.ts": "78126170ab917f0ca355a9af9e65ad6bfa5be14d574c5fb09bb1920f52577780",
|
||||
"https://deno.land/std@0.224.0/path/windows/relative.ts": "3e1abc7977ee6cc0db2730d1f9cb38be87b0ce4806759d271a70e4997fc638d7",
|
||||
"https://deno.land/std@0.224.0/path/windows/resolve.ts": "8dae1dadfed9d46ff46cc337c9525c0c7d959fb400a6308f34595c45bdca1972",
|
||||
"https://deno.land/std@0.224.0/streams/byte_slice_stream.ts": "5bbdcadb118390affa9b3d0a0f73ef8e83754f59bb89df349add669dd9369713",
|
||||
"https://deno.land/std@0.224.0/version.ts": "f6a28c9704d82d1c095988777e30e6172eb674a6570974a0d27a653be769bbbe"
|
||||
},
|
||||
"workspace": {
|
||||
"dependencies": [
|
||||
"jsr:@nostr/tools@^2.12.0",
|
||||
"jsr:@std/assert@1"
|
||||
]
|
||||
}
|
||||
}
|
337
handleUidFetch.ts
Normal file
337
handleUidFetch.ts
Normal file
|
@ -0,0 +1,337 @@
|
|||
import type { Socket } from "node:net";
|
||||
import type { ImapCommandParameters } from "./imapParsing.ts";
|
||||
import { fetchAllEventsMatchingQuery, type ImapSession } from "./imapServer.ts";
|
||||
import * as nip19 from "@nostr/tools/nip19";
|
||||
import type { Event } from "@nostr/tools";
|
||||
import {
|
||||
findUidForEvent,
|
||||
getByteLength,
|
||||
getFlags,
|
||||
parseBasicNostrMarkdown,
|
||||
parseUid,
|
||||
sendSocketMessage,
|
||||
} from "./utils.ts";
|
||||
|
||||
const eventCache: {
|
||||
mailingList: string;
|
||||
timestamp: number;
|
||||
events: Event[];
|
||||
} = {
|
||||
mailingList: "",
|
||||
timestamp: 0,
|
||||
events: [],
|
||||
};
|
||||
|
||||
const CACHE_EXPIRY = 5 * 60 * 1000;
|
||||
|
||||
function buildHeaders(
|
||||
event: Event,
|
||||
session: ImapSession,
|
||||
headersRequested: string[],
|
||||
): string {
|
||||
if (!session.selectedMailingList) {
|
||||
throw new Error("No mailing list selected");
|
||||
}
|
||||
|
||||
let headersResponse = "\r\n";
|
||||
const subjectTag = event.tags.find((tag) =>
|
||||
tag[0].toLowerCase() === "subject"
|
||||
);
|
||||
|
||||
headersResponse += `From: ${nip19.npubEncode(event.pubkey)}@nostr\r\n`;
|
||||
|
||||
headersResponse += `To: ${
|
||||
nip19.npubEncode(session.selectedMailingList)
|
||||
}@nostr\r\n`;
|
||||
|
||||
headersResponse += `Reply-To: ${nip19.noteEncode(event.id)}@nostr\r\n`;
|
||||
|
||||
if (headersRequested.includes("SUBJECT")) {
|
||||
headersResponse += `Subject: ${
|
||||
subjectTag ? subjectTag[1] : "No subject"
|
||||
}\r\n`;
|
||||
}
|
||||
|
||||
if (headersRequested.includes("DATE")) {
|
||||
headersResponse += `Date: ${
|
||||
new Date(event.created_at * 1000).toUTCString()
|
||||
}\r\n`;
|
||||
}
|
||||
|
||||
const threadId = event.tags.find((tag) => tag[0] === "E");
|
||||
const messageThread = [];
|
||||
if (threadId) {
|
||||
messageThread.push(threadId[1]);
|
||||
}
|
||||
headersResponse += `Message-ID: <${event.id}@nostr>\r\n`;
|
||||
|
||||
const eTags = event.tags.filter((tag) => tag[0] === "e");
|
||||
if (eTags.length > 0) {
|
||||
for (const eTag of eTags) {
|
||||
messageThread.push(eTag[1]);
|
||||
}
|
||||
}
|
||||
|
||||
if (messageThread.length > 0) {
|
||||
headersResponse += `References: ${
|
||||
messageThread.map((id) => `<${id}@nostr>`).join(" ")
|
||||
}\r\n`;
|
||||
headersResponse += `In-Reply-To: <${
|
||||
messageThread[messageThread.length - 1]
|
||||
}@nostr>\r\n`;
|
||||
headersResponse += `Thread-Index: ${messageThread.join("")}@nostr\r\n`;
|
||||
headersResponse += `X-Thread-Depth: ${messageThread.length}\r\n`;
|
||||
}
|
||||
|
||||
headersResponse += "Content-Type: text/html; charset=utf-8\r\n";
|
||||
|
||||
headersResponse += "\r\n";
|
||||
return headersResponse;
|
||||
}
|
||||
|
||||
function buildFullMessageBody(event: Event, session: ImapSession): string {
|
||||
if (!session.selectedMailingList) {
|
||||
throw new Error("No mailing list selected");
|
||||
}
|
||||
|
||||
const subjectTag = event.tags.find((tag) =>
|
||||
tag[0].toLowerCase() === "subject"
|
||||
);
|
||||
const subject = subjectTag ? subjectTag[1] : "No subject";
|
||||
const threadTag = event.tags.find((tag) => tag[0] === "E");
|
||||
const threadId = threadTag ? threadTag[1] : event.id;
|
||||
|
||||
const contentParts = [
|
||||
`From: ${nip19.npubEncode(event.pubkey)}@nostr`,
|
||||
`To: ${nip19.npubEncode(session.selectedMailingList)}@nostr`,
|
||||
`Reply-To: ${nip19.noteEncode(event.id)}@nostr`,
|
||||
`Subject: ${subject}`,
|
||||
`Date: ${new Date(event.created_at * 1000).toUTCString()}`,
|
||||
`Message-ID: <${event.id}@nostr>`,
|
||||
"Content-Type: text/html; charset=utf-8",
|
||||
];
|
||||
|
||||
const messageThread = [];
|
||||
|
||||
if (threadId !== event.id) {
|
||||
messageThread.push(threadId);
|
||||
}
|
||||
|
||||
const eTags = event.tags.filter((tag) => tag[0] === "e");
|
||||
if (eTags.length > 0) {
|
||||
for (const eTag of eTags) {
|
||||
messageThread.push(eTag[1]);
|
||||
}
|
||||
}
|
||||
|
||||
if (messageThread.length > 0) {
|
||||
contentParts.push(
|
||||
`References: ${messageThread.map((id) => `<${id}@nostr>`).join(" ")}`,
|
||||
);
|
||||
contentParts.push(
|
||||
`In-Reply-To: <${messageThread[messageThread.length - 1]}@nostr>`,
|
||||
);
|
||||
contentParts.push(
|
||||
`Thread-Index: ${messageThread.join("")}@nostr`,
|
||||
);
|
||||
contentParts.push(
|
||||
`X-Thread-Depth: ${messageThread.length}`,
|
||||
);
|
||||
}
|
||||
|
||||
contentParts.push(
|
||||
"",
|
||||
`${event.content}`,
|
||||
);
|
||||
|
||||
return contentParts.join("\r\n");
|
||||
}
|
||||
|
||||
async function handleDataItem(
|
||||
item: string | Record<string, unknown>,
|
||||
event: Event,
|
||||
session: ImapSession,
|
||||
dataPieces: string[],
|
||||
) {
|
||||
if (item === "UID") return;
|
||||
|
||||
if (item === "FLAGS") {
|
||||
const flags = await getFlags(session, event.id);
|
||||
dataPieces.push(`FLAGS (${flags.join(" ")})`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (item === "RFC822.SIZE") {
|
||||
dataPieces.push(`RFC822.SIZE ${JSON.stringify(event).length}`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof item === "object" && item["BODY.PEEK[HEADER.FIELDS]"]) {
|
||||
const headersRequested = (item["BODY.PEEK[HEADER.FIELDS]"] as string[]).map(
|
||||
(x: string) => x.toUpperCase(),
|
||||
);
|
||||
const headersResponse = buildHeaders(event, session, headersRequested);
|
||||
dataPieces.push(
|
||||
`BODY[HEADER.FIELDS (${
|
||||
(item["BODY.PEEK[HEADER.FIELDS]"] as string[]).join(" ")
|
||||
})] {${headersResponse.length}}${headersResponse}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
typeof item === "string" &&
|
||||
item.startsWith("BODY")
|
||||
) {
|
||||
const contentText = buildFullMessageBody(event, session);
|
||||
const contentHtml = parseBasicNostrMarkdown(contentText);
|
||||
const bodyMessage = `BODY[] {${
|
||||
getByteLength(contentHtml)
|
||||
}}\r\n${contentHtml}`;
|
||||
dataPieces.push(bodyMessage);
|
||||
return;
|
||||
}
|
||||
|
||||
if (item === "BODY.PEEK[HEADER]" || item === "BODY[HEADER]") {
|
||||
const allHeadersResponse = buildHeaders(
|
||||
event,
|
||||
session,
|
||||
[
|
||||
"FROM",
|
||||
"TO",
|
||||
"SUBJECT",
|
||||
"DATE",
|
||||
"CONTENT-TYPE",
|
||||
"MESSAGE-ID",
|
||||
"THREAD-ID",
|
||||
],
|
||||
);
|
||||
dataPieces.push(
|
||||
`BODY[HEADER] {${allHeadersResponse.length}}${allHeadersResponse}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (item === "BODY.PEEK[TEXT]" || item === "BODY[TEXT]") {
|
||||
dataPieces.push(`BODY[TEXT] {${event.content.length}}\r\n${event.content}`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (item === "ENVELOPE") {
|
||||
const subjectTag = event.tags.find((tag) =>
|
||||
tag[0].toLowerCase() === "subject"
|
||||
);
|
||||
const subject = subjectTag ? subjectTag[1] : "No subject";
|
||||
const date = new Date(event.created_at * 1000).toUTCString();
|
||||
|
||||
let envelopeStr = `ENVELOPE ("${date}" "${subject}" (("" NIL "${
|
||||
nip19.npubEncode(event.pubkey)
|
||||
}" "nostr")) (("" NIL "${
|
||||
nip19.npubEncode(event.pubkey)
|
||||
}" "nostr")) (("" NIL "${nip19.npubEncode(event.pubkey)}" "nostr"))`;
|
||||
|
||||
if (session.selectedMailingList) {
|
||||
envelopeStr += ` ((NIL NIL "${
|
||||
nip19.npubEncode(session.selectedMailingList)
|
||||
}" "nostr"))`;
|
||||
} else {
|
||||
envelopeStr += ` ((NIL NIL "unknown" "nostr"))`;
|
||||
}
|
||||
|
||||
envelopeStr += ` NIL NIL NIL "<${event.id}@nostr>")`;
|
||||
dataPieces.push(envelopeStr);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log("Unknown item", item);
|
||||
}
|
||||
|
||||
export async function handleUidFetch(
|
||||
session: ImapSession,
|
||||
socket: Socket,
|
||||
tag: string,
|
||||
uid: ImapCommandParameters,
|
||||
data_items: ImapCommandParameters,
|
||||
): Promise<void> {
|
||||
try {
|
||||
if (!session.selectedMailingList) {
|
||||
sendSocketMessage(socket, `${tag} NO No mailbox selected\r\n`);
|
||||
return;
|
||||
}
|
||||
|
||||
let allThreads: Event[];
|
||||
const now = Date.now();
|
||||
|
||||
if (
|
||||
eventCache.mailingList === session.selectedMailingList &&
|
||||
now - eventCache.timestamp < CACHE_EXPIRY
|
||||
) {
|
||||
allThreads = eventCache.events;
|
||||
} else {
|
||||
allThreads = await fetchAllEventsMatchingQuery([
|
||||
{
|
||||
kinds: [11, 1111],
|
||||
"#P": [session.selectedMailingList],
|
||||
},
|
||||
]);
|
||||
|
||||
eventCache.mailingList = session.selectedMailingList;
|
||||
eventCache.timestamp = now;
|
||||
eventCache.events = allThreads;
|
||||
}
|
||||
|
||||
let start = 0;
|
||||
let end = 0;
|
||||
try {
|
||||
const range = parseUid(uid);
|
||||
start = range[0];
|
||||
end = range[1];
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
sendSocketMessage(
|
||||
socket,
|
||||
`${tag} BAD Invalid UID range: ${error.message}\r\n`,
|
||||
);
|
||||
} else {
|
||||
sendSocketMessage(socket, `${tag} BAD Invalid UID range\r\n`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
let counter = 1;
|
||||
const filteredEvents = allThreads.filter((event) => {
|
||||
const eventUid = findUidForEvent(event);
|
||||
return eventUid >= start && eventUid <= end;
|
||||
});
|
||||
|
||||
filteredEvents.sort((a, b) => findUidForEvent(a) - findUidForEvent(b));
|
||||
|
||||
for (const event of filteredEvents) {
|
||||
const eventUid = findUidForEvent(event);
|
||||
let message = `* ${counter++} FETCH (`;
|
||||
const dataPieces: string[] = [`UID ${eventUid}`];
|
||||
|
||||
if (Array.isArray(data_items)) {
|
||||
for (const item of data_items) {
|
||||
await handleDataItem(item, event, session, dataPieces);
|
||||
}
|
||||
} else {
|
||||
await handleDataItem(data_items, event, session, dataPieces);
|
||||
}
|
||||
|
||||
message += `${dataPieces.join(" ")})\r\n`;
|
||||
sendSocketMessage(socket, message);
|
||||
}
|
||||
|
||||
sendSocketMessage(socket, `${tag} OK UID FETCH completed\r\n`);
|
||||
} catch (error) {
|
||||
console.error("Error in UID FETCH handler:", error);
|
||||
const errorMessage = error instanceof Error
|
||||
? error.message
|
||||
: "Unknown error";
|
||||
sendSocketMessage(
|
||||
socket,
|
||||
`${tag} NO Error processing FETCH: ${errorMessage}\r\n`,
|
||||
);
|
||||
}
|
||||
}
|
175
imapParsing.ts
Normal file
175
imapParsing.ts
Normal file
|
@ -0,0 +1,175 @@
|
|||
export type ImapCommandParameters = string | string[] | Record<string, any>;
|
||||
|
||||
export function parseImapCommand(commandString: string): {
|
||||
tag: string;
|
||||
command: string;
|
||||
parameters: ImapCommandParameters[];
|
||||
} {
|
||||
const parts: string[] = [];
|
||||
let currentPart = "";
|
||||
let inQuotes = false;
|
||||
let parenDepth = 0;
|
||||
let bracketDepth = 0;
|
||||
|
||||
for (let i = 0; i < commandString.length; i++) {
|
||||
const char = commandString[i];
|
||||
|
||||
if (char === '"' && (i === 0 || commandString[i - 1] !== "\\")) {
|
||||
inQuotes = !inQuotes;
|
||||
currentPart += char;
|
||||
} else if (char === "(" && !inQuotes) {
|
||||
parenDepth++;
|
||||
currentPart += char;
|
||||
} else if (char === ")" && !inQuotes) {
|
||||
parenDepth--;
|
||||
currentPart += char;
|
||||
} else if (char === "[" && !inQuotes) {
|
||||
bracketDepth++;
|
||||
currentPart += char;
|
||||
} else if (char === "]" && !inQuotes) {
|
||||
bracketDepth--;
|
||||
currentPart += char;
|
||||
} else if (
|
||||
char === " " && !inQuotes && parenDepth === 0 && bracketDepth === 0
|
||||
) {
|
||||
if (currentPart) {
|
||||
parts.push(currentPart);
|
||||
currentPart = "";
|
||||
}
|
||||
} else {
|
||||
currentPart += char;
|
||||
}
|
||||
}
|
||||
|
||||
if (currentPart) {
|
||||
parts.push(currentPart);
|
||||
}
|
||||
|
||||
if (parts.length < 2) {
|
||||
throw new Error("Invalid command");
|
||||
}
|
||||
|
||||
const tag = parts[0];
|
||||
|
||||
let command = parts[1];
|
||||
let paramStartIdx = 2;
|
||||
|
||||
if (command.toUpperCase() === "UID") {
|
||||
command = `${command} ${parts[2]}`;
|
||||
paramStartIdx = 3;
|
||||
}
|
||||
|
||||
const parameters: ImapCommandParameters[] = [];
|
||||
|
||||
for (let i = paramStartIdx; i < parts.length; i++) {
|
||||
let part = parts[i];
|
||||
|
||||
if (part.startsWith('"') && part.endsWith('"')) {
|
||||
part = part.slice(1, -1);
|
||||
}
|
||||
|
||||
if (part.includes('\\"')) {
|
||||
parameters.push(part.split('\\"'));
|
||||
} else if (part.startsWith("(") && part.endsWith(")")) {
|
||||
parameters.push(parseDataItems(part));
|
||||
} else {
|
||||
parameters.push(part);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
tag,
|
||||
command,
|
||||
parameters,
|
||||
};
|
||||
}
|
||||
|
||||
export function parseDataItems(
|
||||
dataItemsString: string,
|
||||
): ImapCommandParameters[] {
|
||||
const inner = dataItemsString.slice(1, -1).trim();
|
||||
|
||||
if (!inner) return [];
|
||||
|
||||
const result: ImapCommandParameters[] = [];
|
||||
let current = "";
|
||||
let inQuotes = false;
|
||||
let parenDepth = 0;
|
||||
let bracketDepth = 0;
|
||||
let fullSectionId = "";
|
||||
|
||||
for (let i = 0; i < inner.length; i++) {
|
||||
const char = inner[i];
|
||||
|
||||
if (char === '"' && (i === 0 || inner[i - 1] !== "\\")) {
|
||||
inQuotes = !inQuotes;
|
||||
current += char;
|
||||
} else if (char === "[" && !inQuotes) {
|
||||
bracketDepth++;
|
||||
|
||||
if (
|
||||
current.toUpperCase().startsWith("BODY.PEEK") ||
|
||||
current.toUpperCase() === "BODY"
|
||||
) {
|
||||
fullSectionId = current + char;
|
||||
current += char;
|
||||
} else {
|
||||
current += char;
|
||||
}
|
||||
} else if (char === "(" && !inQuotes) {
|
||||
parenDepth++;
|
||||
|
||||
if (bracketDepth > 0 && fullSectionId) {
|
||||
fullSectionId = current;
|
||||
current = "";
|
||||
} else {
|
||||
current += char;
|
||||
}
|
||||
} else if (char === ")" && !inQuotes) {
|
||||
parenDepth--;
|
||||
|
||||
if (parenDepth === 0 && bracketDepth > 0 && fullSectionId) {
|
||||
const fieldList = current.split(/\s+/).filter(Boolean);
|
||||
current = fieldList;
|
||||
} else {
|
||||
current += char;
|
||||
}
|
||||
} else if (char === "]" && !inQuotes) {
|
||||
bracketDepth--;
|
||||
|
||||
if (bracketDepth === 0 && fullSectionId) {
|
||||
if (Array.isArray(current)) {
|
||||
const sectionObj: Record<string, string[]> = {};
|
||||
sectionObj[`${fullSectionId.trim()}]`] = current;
|
||||
result.push(sectionObj);
|
||||
} else {
|
||||
result.push(`${fullSectionId}${current}]`);
|
||||
}
|
||||
|
||||
fullSectionId = "";
|
||||
current = "";
|
||||
} else if (fullSectionId) {
|
||||
current += char;
|
||||
} else {
|
||||
current += char;
|
||||
}
|
||||
} else if (
|
||||
char === " " && !inQuotes && parenDepth === 0 && bracketDepth === 0
|
||||
) {
|
||||
if (current) {
|
||||
result.push(current);
|
||||
current = "";
|
||||
}
|
||||
} else {
|
||||
if (!Array.isArray(current)) {
|
||||
current += char;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (current && !Array.isArray(current)) {
|
||||
result.push(current);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
524
imapServer.ts
Normal file
524
imapServer.ts
Normal file
|
@ -0,0 +1,524 @@
|
|||
import type { Buffer } from "node:buffer";
|
||||
import * as net from "node:net";
|
||||
import type { Event, Filter } from "@nostr/tools";
|
||||
import { getPublicKey, nip19 } from "@nostr/tools";
|
||||
import type { NPub, NSec } from "@nostr/tools/nip19";
|
||||
import { type ImapCommandParameters, parseImapCommand } from "./imapParsing.ts";
|
||||
import { handleUidFetch } from "./handleUidFetch.ts";
|
||||
import {
|
||||
findEventIdForUid,
|
||||
getFlags,
|
||||
logMessage,
|
||||
markMessageAsFlagged,
|
||||
markMessageAsRead,
|
||||
sendSocketMessage,
|
||||
} from "./utils.ts";
|
||||
import { countingRelay, mailingLists, queryingRelay } from "./main.ts";
|
||||
|
||||
export interface ImapSession {
|
||||
npub: NPub | null;
|
||||
selectedMailingList: string | null;
|
||||
}
|
||||
|
||||
function handleLogin(
|
||||
session: ImapSession,
|
||||
socket: net.Socket,
|
||||
tag: string,
|
||||
npubString: ImapCommandParameters,
|
||||
nsecString: ImapCommandParameters,
|
||||
) {
|
||||
if (typeof npubString !== "string" || typeof nsecString !== "string") {
|
||||
sendSocketMessage(socket, `${tag} NO LOGIN failed\r\n`);
|
||||
return;
|
||||
}
|
||||
if (!nsecString.startsWith("nsec1")) {
|
||||
sendSocketMessage(socket, `${tag} NO LOGIN failed\r\n`);
|
||||
return;
|
||||
}
|
||||
|
||||
const sk = nip19.decode<"nsec">(nsecString as NSec);
|
||||
if (!sk?.data) {
|
||||
sendSocketMessage(socket, `${tag} NO LOGIN failed\r\n`);
|
||||
return;
|
||||
}
|
||||
const pkFromSk = getPublicKey(sk.data);
|
||||
const npub = nip19.npubEncode(pkFromSk);
|
||||
|
||||
if (npubString === npub) {
|
||||
session.npub = npub;
|
||||
sendSocketMessage(socket, `${tag} OK LOGIN completed\r\n`);
|
||||
} else {
|
||||
sendSocketMessage(socket, `${tag} NO LOGIN failed\r\n`);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSelect(
|
||||
session: ImapSession,
|
||||
socket: net.Socket,
|
||||
tag: string,
|
||||
mailbox: ImapCommandParameters,
|
||||
) {
|
||||
if (!session.npub) {
|
||||
sendSocketMessage(socket, `${tag} NO Not authenticated\r\n`);
|
||||
return;
|
||||
}
|
||||
const mailboxName = Array.isArray(mailbox)
|
||||
? mailbox[mailbox.length - 1]
|
||||
: mailbox;
|
||||
if (!mailboxName.startsWith("npub")) {
|
||||
sendSocketMessage(
|
||||
socket,
|
||||
`${tag} NO [READ-WRITE] SELECT failed, invalid mailbox\r\n`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const mailboxNpub = nip19.decode<"npub">(mailboxName as NPub);
|
||||
if (!mailboxNpub) {
|
||||
sendSocketMessage(
|
||||
socket,
|
||||
`${tag} NO [READ-WRITE] SELECT failed, invalid mailbox\r\n`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
session.selectedMailingList = mailboxNpub.data;
|
||||
|
||||
const threadsInMailBox = await countingRelay.count(
|
||||
[
|
||||
{
|
||||
kinds: [11, 1111],
|
||||
"#P": [session.selectedMailingList],
|
||||
},
|
||||
],
|
||||
{},
|
||||
);
|
||||
const recentThreads = await countingRelay.count(
|
||||
[
|
||||
{
|
||||
kinds: [11, 1111],
|
||||
"#P": [session.selectedMailingList],
|
||||
since: Math.floor((Date.now() - 1000 * 60 * 60 * 24) / 1000),
|
||||
},
|
||||
],
|
||||
{},
|
||||
);
|
||||
|
||||
sendSocketMessage(
|
||||
socket,
|
||||
"* FLAGS (\\Flagged \\Seen \\Answered)\r\n",
|
||||
);
|
||||
sendSocketMessage(socket, `* ${threadsInMailBox} EXISTS\r\n`);
|
||||
sendSocketMessage(socket, `* ${recentThreads} RECENT\r\n`);
|
||||
sendSocketMessage(socket, `${tag} OK [READ-WRITE] SELECT completed\r\n`);
|
||||
}
|
||||
|
||||
function handleCreate(
|
||||
session: ImapSession,
|
||||
socket: net.Socket,
|
||||
tag: string,
|
||||
mailbox: ImapCommandParameters,
|
||||
) {
|
||||
if (!session.npub) {
|
||||
sendSocketMessage(socket, `${tag} NO Not authenticated\r\n`);
|
||||
return;
|
||||
}
|
||||
|
||||
const mailboxName = Array.isArray(mailbox)
|
||||
? mailbox[mailbox.length - 1]
|
||||
: mailbox;
|
||||
|
||||
if (!mailboxName.startsWith("npub")) {
|
||||
sendSocketMessage(
|
||||
socket,
|
||||
`${tag} NO [READ-WRITE] CREATE failed, invalid mailbox\r\n`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const mailboxNpub = nip19.decode<"npub">(mailboxName as NPub);
|
||||
if (!mailboxNpub) {
|
||||
sendSocketMessage(
|
||||
socket,
|
||||
`${tag} NO [READ-WRITE] CREATE failed, invalid mailbox\r\n`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const mailingList = mailingLists.find(
|
||||
(list: string) => list === mailboxNpub.data,
|
||||
);
|
||||
if (mailingList) {
|
||||
sendSocketMessage(
|
||||
socket,
|
||||
`${tag} NO [READ-WRITE] CREATE failed, mailbox already exists\r\n`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
mailingLists.push(mailboxName);
|
||||
Deno.writeFileSync(
|
||||
"maling-lists.json",
|
||||
new TextEncoder().encode(JSON.stringify(mailingLists)),
|
||||
);
|
||||
|
||||
sendSocketMessage(socket, `${tag} OK [READ-WRITE] CREATE completed\r\n`);
|
||||
}
|
||||
|
||||
function handleLogout(socket: net.Socket, tag: string) {
|
||||
sendSocketMessage(socket, "* BYE IMAP server logging out\r\n");
|
||||
sendSocketMessage(socket, `${tag} OK LOGOUT completed\r\n`);
|
||||
socket.end();
|
||||
}
|
||||
|
||||
function handleList(
|
||||
session: ImapSession,
|
||||
socket: net.Socket,
|
||||
tag: string,
|
||||
_: ImapCommandParameters,
|
||||
filter: ImapCommandParameters,
|
||||
) {
|
||||
if (!session.npub) {
|
||||
sendSocketMessage(socket, `${tag} NO Not authenticated\r\n`);
|
||||
return;
|
||||
}
|
||||
|
||||
let filterMailingList = Array.isArray(filter)
|
||||
? filter[filter.length - 1]
|
||||
: filter;
|
||||
|
||||
if (filterMailingList.includes('"')) {
|
||||
filterMailingList = filterMailingList.split('"')[1];
|
||||
}
|
||||
|
||||
if (filterMailingList.startsWith("npub")) {
|
||||
const mailboxNpub = nip19.decode<"npub">(filterMailingList as NPub);
|
||||
if (!mailboxNpub) {
|
||||
sendSocketMessage(
|
||||
socket,
|
||||
`${tag} NO [READ-WRITE] LIST failed, invalid mailbox\r\n`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const mailingList = mailingLists.find(
|
||||
(list: string) => list === filterMailingList,
|
||||
);
|
||||
if (!mailingList) {
|
||||
sendSocketMessage(
|
||||
socket,
|
||||
`${tag} NO [READ-WRITE] LIST failed, mailbox does not exist\r\n`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
sendSocketMessage(
|
||||
socket,
|
||||
`* LIST (\\HasNoChildren) "" ${filterMailingList}\r\n`,
|
||||
);
|
||||
sendSocketMessage(socket, `${tag} OK LIST completed\r\n`);
|
||||
} else if (filterMailingList === "*") {
|
||||
for (const mailingList of mailingLists) {
|
||||
sendSocketMessage(
|
||||
socket,
|
||||
`* LIST (\\HasNoChildren) "" ${mailingList}\r\n`,
|
||||
);
|
||||
}
|
||||
sendSocketMessage(socket, `${tag} OK LIST completed\r\n`);
|
||||
}
|
||||
}
|
||||
|
||||
function handleLsub(
|
||||
session: ImapSession,
|
||||
socket: net.Socket,
|
||||
tag: string,
|
||||
reference: ImapCommandParameters,
|
||||
mailbox: ImapCommandParameters,
|
||||
) {
|
||||
if (!session.npub) {
|
||||
sendSocketMessage(socket, `${tag} NO Not authenticated\r\n`);
|
||||
return;
|
||||
}
|
||||
|
||||
const referenceName = Array.isArray(reference)
|
||||
? reference[reference.length - 1]
|
||||
: reference;
|
||||
const mailboxName = Array.isArray(mailbox)
|
||||
? mailbox[mailbox.length - 1]
|
||||
: mailbox;
|
||||
|
||||
if (referenceName !== "") {
|
||||
sendSocketMessage(socket, `${tag} NO LSUB failed, invalid reference\r\n`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!mailboxName.endsWith("*")) {
|
||||
sendSocketMessage(socket, `${tag} NO LSUB failed, invalid mailbox\r\n`);
|
||||
return;
|
||||
}
|
||||
|
||||
for (const mailingList of mailingLists) {
|
||||
sendSocketMessage(socket, `* LSUB () "" ${mailingList}\r\n`);
|
||||
}
|
||||
sendSocketMessage(socket, `${tag} OK LSUB completed\r\n`);
|
||||
}
|
||||
|
||||
function handleSubscribe(
|
||||
session: ImapSession,
|
||||
socket: net.Socket,
|
||||
tag: string,
|
||||
mailbox: ImapCommandParameters,
|
||||
) {
|
||||
if (!session.npub) {
|
||||
sendSocketMessage(socket, `${tag} NO Not authenticated\r\n`);
|
||||
return;
|
||||
}
|
||||
|
||||
const mailboxName = Array.isArray(mailbox)
|
||||
? mailbox[mailbox.length - 1]
|
||||
: mailbox;
|
||||
|
||||
if (!mailboxName.startsWith("npub")) {
|
||||
sendSocketMessage(
|
||||
socket,
|
||||
`${tag} NO SUBSCRIBE failed, invalid mailbox\r\n`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const mailingList = mailingLists.find(
|
||||
(list: string) => list === mailboxName,
|
||||
);
|
||||
if (!mailingList) {
|
||||
sendSocketMessage(
|
||||
socket,
|
||||
`${tag} NO SUBSCRIBE failed, mailbox does not exist\r\n`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
sendSocketMessage(socket, `${tag} OK SUBSCRIBE completed\r\n`);
|
||||
}
|
||||
|
||||
export function fetchAllEventsMatchingQuery(query: Filter[]): Promise<Event[]> {
|
||||
return new Promise((resolve) => {
|
||||
const allEvents: Event[] = [];
|
||||
const allThreadsSubscription = queryingRelay.subscribe(
|
||||
query,
|
||||
{
|
||||
onevent: (event) => {
|
||||
allEvents.push(event);
|
||||
},
|
||||
oneose: () => {
|
||||
allThreadsSubscription.close();
|
||||
resolve(allEvents);
|
||||
},
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
async function handleUidStore(
|
||||
session: ImapSession,
|
||||
socket: net.Socket,
|
||||
tag: string,
|
||||
uid: ImapCommandParameters,
|
||||
storeItem: ImapCommandParameters,
|
||||
value: ImapCommandParameters,
|
||||
) {
|
||||
if (!session.npub) {
|
||||
sendSocketMessage(socket, `${tag} NO Not authenticated\r\n`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof uid !== "string") {
|
||||
sendSocketMessage(socket, `${tag} BAD UID STORE failed, invalid uid\r\n`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
typeof storeItem !== "string" ||
|
||||
storeItem.toLowerCase() !== "+flags" && storeItem.toLowerCase() !== "-flags"
|
||||
) {
|
||||
sendSocketMessage(
|
||||
socket,
|
||||
`${tag} BAD UID STORE failed, invalid store item\r\n`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!Array.isArray(value)) {
|
||||
sendSocketMessage(socket, `${tag} BAD UID STORE failed, invalid value\r\n`);
|
||||
return;
|
||||
}
|
||||
|
||||
const eventId = findEventIdForUid(Number.parseInt(uid));
|
||||
|
||||
for (const flag of value) {
|
||||
if (flag.toLowerCase() === "\\seen") {
|
||||
await markMessageAsRead(
|
||||
session,
|
||||
eventId,
|
||||
storeItem.toLowerCase() === "+flags",
|
||||
);
|
||||
}
|
||||
if (flag.toLowerCase() === "\\flagged") {
|
||||
await markMessageAsFlagged(
|
||||
session,
|
||||
eventId,
|
||||
storeItem.toLowerCase() === "+flags",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const flags = await getFlags(session, eventId);
|
||||
|
||||
sendSocketMessage(
|
||||
socket,
|
||||
`* 1 FETCH (FLAGS (${flags.join(" ")}) UID ${uid})\r\n`,
|
||||
);
|
||||
|
||||
sendSocketMessage(socket, `${tag} OK UID STORE completed\r\n`);
|
||||
}
|
||||
|
||||
export default function startImapServer(): void {
|
||||
const server = net.createServer((socket: net.Socket) => {
|
||||
const session: ImapSession = {
|
||||
npub: null,
|
||||
selectedMailingList: null,
|
||||
};
|
||||
|
||||
try {
|
||||
sendSocketMessage(socket, "* OK Nostr over IMAP ready\r\n");
|
||||
} catch (e) {
|
||||
console.error("Error sending initial greeting:", e);
|
||||
socket.end();
|
||||
return;
|
||||
}
|
||||
|
||||
socket.on("data", async (data: Buffer) => {
|
||||
if (socket.destroyed || socket.closed) {
|
||||
console.error("Socket is destroyed or closed");
|
||||
return;
|
||||
}
|
||||
logMessage("in", data.toString());
|
||||
|
||||
const command = parseImapCommand(data.toString().trim());
|
||||
|
||||
console.log(command.command);
|
||||
|
||||
if (!command.command) {
|
||||
sendSocketMessage(
|
||||
socket,
|
||||
`${command.tag} BAD Command not recognized\r\n`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
switch (command.command.toUpperCase()) {
|
||||
case "CAPABILITY":
|
||||
sendSocketMessage(
|
||||
socket,
|
||||
"* CAPABILITY IMAP4rev1 THREAD=REFERENCES\r\n",
|
||||
);
|
||||
sendSocketMessage(
|
||||
socket,
|
||||
`${command.tag} OK CAPABILITY completed\r\n`,
|
||||
);
|
||||
break;
|
||||
|
||||
case "LOGIN":
|
||||
handleLogin(
|
||||
session,
|
||||
socket,
|
||||
command.tag,
|
||||
command.parameters[0],
|
||||
command.parameters[1],
|
||||
);
|
||||
break;
|
||||
|
||||
case "CREATE":
|
||||
handleCreate(session, socket, command.tag, command.parameters[0]);
|
||||
break;
|
||||
|
||||
case "LIST":
|
||||
handleList(
|
||||
session,
|
||||
socket,
|
||||
command.tag,
|
||||
command.parameters[0],
|
||||
command.parameters[1],
|
||||
);
|
||||
break;
|
||||
|
||||
case "LSUB":
|
||||
handleLsub(
|
||||
session,
|
||||
socket,
|
||||
command.tag,
|
||||
command.parameters[0],
|
||||
command.parameters[1],
|
||||
);
|
||||
break;
|
||||
|
||||
case "SUBSCRIBE":
|
||||
handleSubscribe(session, socket, command.tag, command.parameters[0]);
|
||||
break;
|
||||
|
||||
case "SELECT":
|
||||
await handleSelect(
|
||||
session,
|
||||
socket,
|
||||
command.tag,
|
||||
command.parameters[0],
|
||||
);
|
||||
break;
|
||||
|
||||
case "LOGOUT":
|
||||
handleLogout(socket, command.tag);
|
||||
break;
|
||||
|
||||
case "UID FETCH":
|
||||
await handleUidFetch(
|
||||
session,
|
||||
socket,
|
||||
command.tag,
|
||||
command.parameters[0],
|
||||
command.parameters[1],
|
||||
);
|
||||
break;
|
||||
|
||||
case "UID STORE":
|
||||
await handleUidStore(
|
||||
session,
|
||||
socket,
|
||||
command.tag,
|
||||
command.parameters[0],
|
||||
command.parameters[1],
|
||||
command.parameters[2],
|
||||
);
|
||||
break;
|
||||
|
||||
default:
|
||||
console.log("Invalid command", command.command);
|
||||
sendSocketMessage(
|
||||
socket,
|
||||
`${command.tag} BAD Command not recognized\r\n`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
socket.on("error", (err: Error) => {
|
||||
console.error("Socket error:", err);
|
||||
if (socket.destroyed || socket.closed) {
|
||||
return;
|
||||
}
|
||||
socket.end();
|
||||
});
|
||||
});
|
||||
|
||||
const PORT = 1143;
|
||||
server.listen(PORT, () => {
|
||||
console.log(`IMAP server listening on port ${PORT}`);
|
||||
});
|
||||
}
|
16
main.ts
Normal file
16
main.ts
Normal file
|
@ -0,0 +1,16 @@
|
|||
import { Relay } from "@nostr/tools/relay";
|
||||
import startImapServer from "./imapServer.ts";
|
||||
import startSmtpServer from "./smtpServer.ts";
|
||||
import { getFileOrDefault } from "./utils.ts";
|
||||
|
||||
export const queryingRelay = new Relay("wss://relay.damus.io");
|
||||
export const countingRelay = new Relay("wss://relay.nostr.band");
|
||||
await queryingRelay.connect();
|
||||
await countingRelay.connect();
|
||||
|
||||
export const mailingLists = JSON.parse(
|
||||
await getFileOrDefault("mailing-lists.json", "[]"),
|
||||
);
|
||||
|
||||
startImapServer();
|
||||
startSmtpServer();
|
374
smtpServer.ts
Normal file
374
smtpServer.ts
Normal file
|
@ -0,0 +1,374 @@
|
|||
import * as net from "node:net";
|
||||
import { finalizeEvent, getPublicKey } from "@nostr/tools";
|
||||
import { nip19 } from "@nostr/tools";
|
||||
import type { Note, NSec } from "@nostr/tools/nip19";
|
||||
import type { Event, EventTemplate } from "@nostr/tools/core";
|
||||
import { Relay } from "@nostr/tools/relay";
|
||||
import { logMessage, sendSocketMessage } from "./utils.ts";
|
||||
import { mailingLists, queryingRelay } from "./main.ts";
|
||||
|
||||
const publishingRelay = new Relay("wss://relay.damus.io");
|
||||
await publishingRelay.connect();
|
||||
|
||||
interface SmtpSession {
|
||||
authenticated: boolean;
|
||||
privateKey: Uint8Array | null;
|
||||
from: string | null;
|
||||
recipients: string[];
|
||||
data: string[];
|
||||
dataMode: boolean;
|
||||
}
|
||||
|
||||
function parseSmtpCommand(
|
||||
line: string,
|
||||
): { command: string; parameters: string[] } {
|
||||
const parts = line.split(" ");
|
||||
const command = parts[0].toUpperCase();
|
||||
const parameters = parts.slice(1);
|
||||
return { command, parameters };
|
||||
}
|
||||
|
||||
function handleEhlo(
|
||||
socket: net.Socket,
|
||||
parameters: string[],
|
||||
): void {
|
||||
const domain = parameters[0] || "localhost";
|
||||
sendSocketMessage(socket, `250-Hello ${domain}\r\n`);
|
||||
sendSocketMessage(socket, "250-AUTH PLAIN\r\n");
|
||||
sendSocketMessage(socket, "250 8BITMIME\r\n");
|
||||
}
|
||||
|
||||
function handleAuth(
|
||||
session: SmtpSession,
|
||||
socket: net.Socket,
|
||||
parameters: string[],
|
||||
): void {
|
||||
if (parameters[0] !== "PLAIN") {
|
||||
sendSocketMessage(socket, "504 Unrecognized authentication type\r\n");
|
||||
return;
|
||||
}
|
||||
|
||||
const credentials = parameters[1];
|
||||
if (!credentials) {
|
||||
sendSocketMessage(socket, "334 \r\n");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const decoded = atob(credentials);
|
||||
const parts = decoded.split("\0");
|
||||
if (parts.length !== 3) {
|
||||
sendSocketMessage(socket, "535 Authentication failed\r\n");
|
||||
return;
|
||||
}
|
||||
|
||||
const npubString = parts[1];
|
||||
const nsecString = parts[2];
|
||||
|
||||
if (!nsecString.startsWith("nsec1")) {
|
||||
sendSocketMessage(socket, "535 Authentication failed\r\n");
|
||||
return;
|
||||
}
|
||||
|
||||
const sk = nip19.decode<"nsec">(nsecString as NSec);
|
||||
if (!sk?.data) {
|
||||
sendSocketMessage(socket, "535 Authentication failed\r\n");
|
||||
return;
|
||||
}
|
||||
|
||||
const pkFromSk = getPublicKey(sk.data);
|
||||
const npub = nip19.npubEncode(pkFromSk);
|
||||
|
||||
if (npubString === npub) {
|
||||
session.authenticated = true;
|
||||
session.privateKey = sk.data;
|
||||
sendSocketMessage(socket, "235 Authentication successful\r\n");
|
||||
} else {
|
||||
sendSocketMessage(socket, "535 Authentication failed\r\n");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Auth error:", error);
|
||||
sendSocketMessage(socket, "535 Authentication failed\r\n");
|
||||
}
|
||||
}
|
||||
|
||||
function handleMailFrom(
|
||||
session: SmtpSession,
|
||||
socket: net.Socket,
|
||||
parameters: string[],
|
||||
): void {
|
||||
if (!session.authenticated) {
|
||||
sendSocketMessage(socket, "530 Authentication required\r\n");
|
||||
return;
|
||||
}
|
||||
|
||||
const fromMatch = parameters[0].match(/<(.+)>/);
|
||||
if (!fromMatch) {
|
||||
sendSocketMessage(socket, "501 Syntax error in parameters\r\n");
|
||||
return;
|
||||
}
|
||||
|
||||
session.from = fromMatch[1];
|
||||
session.recipients = [];
|
||||
session.data = [];
|
||||
session.dataMode = false;
|
||||
sendSocketMessage(socket, "250 OK\r\n");
|
||||
}
|
||||
|
||||
function handleRcptTo(
|
||||
session: SmtpSession,
|
||||
socket: net.Socket,
|
||||
parameters: string[],
|
||||
): void {
|
||||
if (!session.authenticated) {
|
||||
sendSocketMessage(socket, "530 Authentication required\r\n");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!session.from) {
|
||||
sendSocketMessage(socket, "503 MAIL command required first\r\n");
|
||||
return;
|
||||
}
|
||||
|
||||
const toMatch = parameters[0].match(/<(.+)>/);
|
||||
if (!toMatch) {
|
||||
sendSocketMessage(socket, "501 Syntax error in parameters\r\n");
|
||||
return;
|
||||
}
|
||||
|
||||
const recipient = toMatch[1];
|
||||
session.recipients.push(recipient);
|
||||
sendSocketMessage(socket, "250 OK\r\n");
|
||||
}
|
||||
|
||||
function handleData(session: SmtpSession, socket: net.Socket): void {
|
||||
if (!session.authenticated) {
|
||||
sendSocketMessage(socket, "530 Authentication required\r\n");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!session.from) {
|
||||
sendSocketMessage(socket, "503 MAIL command required first\r\n");
|
||||
return;
|
||||
}
|
||||
|
||||
if (session.recipients.length === 0) {
|
||||
sendSocketMessage(socket, "503 RCPT command required first\r\n");
|
||||
return;
|
||||
}
|
||||
|
||||
session.dataMode = true;
|
||||
sendSocketMessage(socket, "354 End data with <CR><LF>.<CR><LF>\r\n");
|
||||
}
|
||||
|
||||
async function publishNostrEvent(session: SmtpSession): Promise<boolean> {
|
||||
if (
|
||||
!session.privateKey || !session.from || session.recipients.length === 0 ||
|
||||
session.data.length === 0
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const content = session.data.join("\r\n");
|
||||
let subject = "";
|
||||
|
||||
console.log("content", content);
|
||||
|
||||
const subjectMatch = content.match(/^Subject: (.+)$/m);
|
||||
if (subjectMatch) {
|
||||
subject = subjectMatch[1];
|
||||
}
|
||||
|
||||
const messageIdMatch = content.match(/^Reply-To: (.+)$/m);
|
||||
const tags = [];
|
||||
if (messageIdMatch) {
|
||||
const messageIdEncoded = messageIdMatch[1].split("@")[0].replace(
|
||||
/-/g,
|
||||
"",
|
||||
);
|
||||
const messageId = nip19.decode<"note">(messageIdEncoded as Note);
|
||||
if (!messageId?.data) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const nostrEvent = await new Promise<Event>((resolve) => {
|
||||
queryingRelay.subscribe([{
|
||||
ids: [messageId.data],
|
||||
limit: 1,
|
||||
}], {
|
||||
onevent(evt) {
|
||||
resolve(evt as Event);
|
||||
},
|
||||
});
|
||||
});
|
||||
tags.push([
|
||||
...nostrEvent.tags.filter((tag) =>
|
||||
tag[0] === "p" || tag[0] === "e" || tag[0] === "E"
|
||||
),
|
||||
]);
|
||||
tags.push(["e", nostrEvent.id]);
|
||||
}
|
||||
|
||||
const bodyIndex = content.indexOf("\r\n\r\n");
|
||||
const messageBody = bodyIndex !== -1
|
||||
? content.substring(bodyIndex + 4)
|
||||
: content;
|
||||
|
||||
for (const recipientMail of session.recipients) {
|
||||
const recipient = recipientMail.split("@")[0];
|
||||
if (recipient.startsWith("npub")) {
|
||||
try {
|
||||
const decodedNpub = nip19.decode(recipient);
|
||||
if (decodedNpub.type === "npub") {
|
||||
if (mailingLists.includes(recipient)) {
|
||||
tags.push(["P", decodedNpub.data]);
|
||||
} else {
|
||||
tags.push(["p", decodedNpub.data]);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
console.error("Invalid npub:", recipient);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (subject) {
|
||||
tags.push(["subject", subject]);
|
||||
}
|
||||
|
||||
const unsignedEvent = {
|
||||
kind: 1111,
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
tags: tags,
|
||||
content: messageBody,
|
||||
pubkey: getPublicKey(session.privateKey),
|
||||
};
|
||||
|
||||
const event = finalizeEvent(
|
||||
unsignedEvent as EventTemplate,
|
||||
session.privateKey,
|
||||
);
|
||||
|
||||
await publishingRelay.publish(event);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error("Failed to publish Nostr event:", error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleQuit(socket: net.Socket): void {
|
||||
sendSocketMessage(socket, "221 Bye\r\n");
|
||||
socket.end();
|
||||
}
|
||||
|
||||
export default function startSmtpServer() {
|
||||
const server = net.createServer((socket: net.Socket) => {
|
||||
const session: SmtpSession = {
|
||||
authenticated: false,
|
||||
privateKey: null,
|
||||
from: null,
|
||||
recipients: [],
|
||||
data: [],
|
||||
dataMode: false,
|
||||
};
|
||||
|
||||
sendSocketMessage(socket, "220 Nostr SMTP Service Ready\r\n");
|
||||
|
||||
socket.on("data", async (data) => {
|
||||
if (socket.destroyed || socket.closed) {
|
||||
return;
|
||||
}
|
||||
|
||||
const message = data.toString();
|
||||
logMessage("smtp-in", message);
|
||||
|
||||
if (session.dataMode) {
|
||||
if (message.trim() === ".") {
|
||||
session.dataMode = false;
|
||||
const success = await publishNostrEvent(session);
|
||||
if (success) {
|
||||
sendSocketMessage(
|
||||
socket,
|
||||
"250 OK: Message accepted for delivery\r\n",
|
||||
);
|
||||
} else {
|
||||
sendSocketMessage(socket, "554 Transaction failed\r\n");
|
||||
}
|
||||
|
||||
session.data = [];
|
||||
session.from = null;
|
||||
session.recipients = [];
|
||||
} else {
|
||||
if (message.startsWith("..")) {
|
||||
session.data.push(message.substring(1));
|
||||
} else {
|
||||
session.data.push(message);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const lines = message.split("\r\n").filter((line) => line.trim() !== "");
|
||||
|
||||
for (const line of lines) {
|
||||
const { command, parameters } = parseSmtpCommand(line);
|
||||
|
||||
switch (command) {
|
||||
case "EHLO":
|
||||
case "HELO":
|
||||
handleEhlo(socket, parameters);
|
||||
break;
|
||||
|
||||
case "AUTH":
|
||||
handleAuth(session, socket, parameters);
|
||||
break;
|
||||
|
||||
case "MAIL":
|
||||
handleMailFrom(session, socket, parameters);
|
||||
break;
|
||||
|
||||
case "RCPT":
|
||||
handleRcptTo(session, socket, parameters);
|
||||
break;
|
||||
|
||||
case "DATA":
|
||||
handleData(session, socket);
|
||||
break;
|
||||
|
||||
case "QUIT":
|
||||
handleQuit(socket);
|
||||
break;
|
||||
|
||||
case "RSET":
|
||||
session.from = null;
|
||||
session.recipients = [];
|
||||
session.data = [];
|
||||
session.dataMode = false;
|
||||
sendSocketMessage(socket, "250 OK\r\n");
|
||||
break;
|
||||
|
||||
case "NOOP":
|
||||
sendSocketMessage(socket, "250 OK\r\n");
|
||||
break;
|
||||
|
||||
default:
|
||||
sendSocketMessage(socket, "502 Command not implemented\r\n");
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
socket.on("error", (err) => {
|
||||
console.error("SMTP Socket error:", err);
|
||||
if (!socket.destroyed && !socket.closed) {
|
||||
socket.end();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const SMTP_PORT = 1025;
|
||||
server.listen(SMTP_PORT, () => {
|
||||
console.log(`SMTP server listening on port ${SMTP_PORT}`);
|
||||
});
|
||||
}
|
4
test.txt
Normal file
4
test.txt
Normal file
|
@ -0,0 +1,4 @@
|
|||
random nsec: nsec1dzk0kv5gfullvszzjcd6692g6gke6dxkjtp2lz6skvsec6xgkv2s3vmd6r
|
||||
random npub: npub1gl5r3h88jatwznxl2tek8hlx975p97k2446azsanzxndzdtp0f6qwkngvn
|
||||
|
||||
mailing list npub: npub12ulmw3a4qyfkuvchl5sh82fcfmagur7wn6c3zas0huyszwadnq6sa4s32m
|
10
tsconfig.json
Normal file
10
tsconfig.json
Normal file
|
@ -0,0 +1,10 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2024",
|
||||
"module": "ESNext",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true
|
||||
}
|
||||
}
|
218
utils.ts
Normal file
218
utils.ts
Normal file
|
@ -0,0 +1,218 @@
|
|||
import type * as net from "node:net";
|
||||
import type { Event } from "@nostr/tools";
|
||||
import { exists } from "jsr:@std/fs/exists";
|
||||
import type { ImapCommandParameters } from "./imapParsing.ts";
|
||||
import type { ImapSession } from "./imapServer.ts";
|
||||
|
||||
export async function isMessageRead(
|
||||
session: ImapSession,
|
||||
eventId: string,
|
||||
): Promise<boolean> {
|
||||
if (!session.npub) {
|
||||
return false;
|
||||
}
|
||||
const readMessages = JSON.parse(
|
||||
await getFileOrDefault("readMessages.json", "{}"),
|
||||
);
|
||||
const readMessagesForUser = readMessages[session.npub];
|
||||
if (!readMessagesForUser) {
|
||||
return false;
|
||||
}
|
||||
return readMessagesForUser.includes(eventId);
|
||||
}
|
||||
|
||||
export async function markMessageAsRead(
|
||||
session: ImapSession,
|
||||
eventId: string,
|
||||
positive = true,
|
||||
) {
|
||||
if (!session.npub) {
|
||||
return;
|
||||
}
|
||||
const readMessages = JSON.parse(
|
||||
await getFileOrDefault("readMessages.json", "{}"),
|
||||
);
|
||||
readMessages[session.npub] = readMessages[session.npub] || [];
|
||||
if (positive) {
|
||||
readMessages[session.npub].push(eventId);
|
||||
} else {
|
||||
readMessages[session.npub] = readMessages[session.npub].filter(
|
||||
(id: string) => id !== eventId,
|
||||
);
|
||||
}
|
||||
await Deno.writeTextFile(
|
||||
"data/readMessages.json",
|
||||
JSON.stringify(readMessages, null, 2),
|
||||
);
|
||||
}
|
||||
|
||||
export async function isMessageFlagged(
|
||||
session: ImapSession,
|
||||
eventId: string,
|
||||
): Promise<boolean> {
|
||||
if (!session.npub) {
|
||||
return false;
|
||||
}
|
||||
const flaggedMessages = JSON.parse(
|
||||
await getFileOrDefault("flaggedMessages.json", "{}"),
|
||||
);
|
||||
const flaggedMessagesForUser = flaggedMessages[session.npub];
|
||||
if (!flaggedMessagesForUser) {
|
||||
return false;
|
||||
}
|
||||
return flaggedMessagesForUser.includes(eventId);
|
||||
}
|
||||
|
||||
export async function markMessageAsFlagged(
|
||||
session: ImapSession,
|
||||
eventId: string,
|
||||
positive = true,
|
||||
) {
|
||||
if (!session.npub) {
|
||||
return;
|
||||
}
|
||||
const flaggedMessages = JSON.parse(
|
||||
await getFileOrDefault("flaggedMessages.json", "{}"),
|
||||
);
|
||||
flaggedMessages[session.npub] = flaggedMessages[session.npub] || [];
|
||||
if (positive) {
|
||||
flaggedMessages[session.npub].push(eventId);
|
||||
} else {
|
||||
flaggedMessages[session.npub] = flaggedMessages[session.npub].filter(
|
||||
(id: string) => id !== eventId,
|
||||
);
|
||||
}
|
||||
await Deno.writeTextFile(
|
||||
"data/flaggedMessages.json",
|
||||
JSON.stringify(flaggedMessages, null, 2),
|
||||
);
|
||||
}
|
||||
|
||||
export async function getFlags(
|
||||
session: ImapSession,
|
||||
eventId: string,
|
||||
): Promise<string[]> {
|
||||
const isRead = await isMessageRead(session, eventId);
|
||||
const isFlagged = await isMessageFlagged(session, eventId);
|
||||
return [isRead ? "\\Seen" : "", isFlagged ? "\\Flagged" : ""].filter(Boolean);
|
||||
}
|
||||
|
||||
export function parseUid(uid: ImapCommandParameters): [number, number] {
|
||||
if (typeof uid !== "string") {
|
||||
throw new Error("Invalid UID");
|
||||
}
|
||||
const [start, end] = uid.split(":");
|
||||
if (end === undefined) {
|
||||
return [Number.parseInt(start), Number.parseInt(start)];
|
||||
}
|
||||
if (start === "*") {
|
||||
return [Number.NEGATIVE_INFINITY, Number.POSITIVE_INFINITY];
|
||||
}
|
||||
if (end === "*") {
|
||||
return [Number.parseInt(start), Number.POSITIVE_INFINITY];
|
||||
}
|
||||
return [Number.parseInt(start), Number.parseInt(end)];
|
||||
}
|
||||
|
||||
export function getByteLength(text: string): number {
|
||||
return new Blob([text]).size;
|
||||
}
|
||||
|
||||
export function parseBasicNostrMarkdown(text: string): string {
|
||||
let generatedHtml = text.replaceAll("<", "<");
|
||||
generatedHtml = generatedHtml.replaceAll(">", ">");
|
||||
|
||||
generatedHtml = generatedHtml.replace(/\*\*(.*?)\*\*/g, "<b>$1</b>");
|
||||
|
||||
generatedHtml = generatedHtml.replace(/\*(.*?)\*/g, "<i>$1</i>");
|
||||
|
||||
generatedHtml = generatedHtml.replace(/`(.*?)`/g, "<code>$1</code>");
|
||||
|
||||
generatedHtml = generatedHtml.replace(/^###\s+(.*?)$/gm, "<h3>$1</h3>");
|
||||
generatedHtml = generatedHtml.replace(/^##\s+(.*?)$/gm, "<h2>$1</h2>");
|
||||
generatedHtml = generatedHtml.replace(/^#\s+(.*?)$/gm, "<h1>$1</h1>");
|
||||
|
||||
generatedHtml = generatedHtml.replace(
|
||||
/!\[(.*?)\]\((https?:\/\/[^\s)]+\.(jpg|jpeg|png|gif|webp))\)/g,
|
||||
'<img src="$2" alt="$1">',
|
||||
);
|
||||
generatedHtml = generatedHtml.replace(
|
||||
/(https?:\/\/[^\s)]+)/g,
|
||||
'<img src="$1" alt="$1">',
|
||||
);
|
||||
|
||||
generatedHtml = generatedHtml.replace(
|
||||
/nostr:(npub1[a-z0-9]+)/g,
|
||||
'<a href="nostr:$1">$1</a>',
|
||||
);
|
||||
generatedHtml = generatedHtml.replace(
|
||||
/nostr:(note1[a-z0-9]+)/g,
|
||||
'<a href="nostr:$1">$1</a>',
|
||||
);
|
||||
generatedHtml = generatedHtml.replace(
|
||||
/nostr:(event1[a-z0-9]+)/g,
|
||||
'<a href="nostr:$1">$1</a>',
|
||||
);
|
||||
|
||||
generatedHtml = generatedHtml.replace(/\n\n/g, "<br><br>");
|
||||
|
||||
return generatedHtml;
|
||||
}
|
||||
|
||||
const eventIdToUidMap: string[] = JSON.parse(
|
||||
await getFileOrDefault("event-id-to-uid-map.json", "[]"),
|
||||
);
|
||||
|
||||
export function findEventIdForUid(uid: number): string {
|
||||
return eventIdToUidMap[uid];
|
||||
}
|
||||
|
||||
export function findUidForEvent(event: Event): number {
|
||||
const eventId = event.id;
|
||||
const uid = eventIdToUidMap.findIndex((id) => id === eventId);
|
||||
if (uid !== -1) {
|
||||
return uid;
|
||||
}
|
||||
eventIdToUidMap.push(eventId);
|
||||
Deno.writeFileSync(
|
||||
"event-id-to-uid-map.json",
|
||||
new TextEncoder().encode(JSON.stringify(eventIdToUidMap)),
|
||||
);
|
||||
return eventIdToUidMap.length;
|
||||
}
|
||||
|
||||
export function sendSocketMessage(socket: net.Socket, message: string) {
|
||||
if (socket.destroyed || socket.closed) {
|
||||
console.error("Socket is destroyed or closed");
|
||||
return;
|
||||
}
|
||||
socket.write(message);
|
||||
logMessage("out", message);
|
||||
}
|
||||
|
||||
export function logMessage(direction: string, message: string) {
|
||||
Deno.writeTextFileSync(
|
||||
"data/message-logs.txt",
|
||||
`${
|
||||
JSON.stringify({
|
||||
direction,
|
||||
message,
|
||||
})
|
||||
}\n`,
|
||||
{
|
||||
append: true,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export async function getFileOrDefault(path: string, defaultValue: string) {
|
||||
if (!await exists("data")) {
|
||||
await Deno.mkdir("data");
|
||||
}
|
||||
|
||||
if (!await exists(`data/${path}`)) {
|
||||
await Deno.writeTextFile(`data/${path}`, defaultValue);
|
||||
}
|
||||
|
||||
return await Deno.readTextFile(`data/${path}`);
|
||||
}
|
Loading…
Add table
Reference in a new issue