diff --git a/.gitignore b/.gitignore index 5262879..de789ef 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ sendgrid.env messages/ -node_modules/ \ No newline at end of file +node_modules/ +state.integer \ No newline at end of file diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..f8f5445 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "static/jmapjs"] + path = static/jmapjs + url = https://github.com/jmapio/jmap-demo-webmail diff --git a/lib/MimeMessage.js b/lib/MimeMessage.js index 32b8e26..ef6bc1b 100644 --- a/lib/MimeMessage.js +++ b/lib/MimeMessage.js @@ -1,4 +1,4 @@ -import MimeHeader from "./MimeHeader"; +import MimeHeader from "./MimeHeader.js"; class MimeMessage { constructor(message) { diff --git a/package-lock.json b/package-lock.json index 420e4e5..642092b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,7 @@ "@sendgrid/mail": "^8.1.4", "body-parser": "^1.20.3", "cookie-parser": "^1.4.7", + "cors": "^2.8.5", "express": "^4.21.2", "formidable": "^3.5.2" } @@ -224,6 +225,19 @@ "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==", "license": "MIT" }, + "node_modules/cors": { + "version": "2.8.5", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz", + "integrity": "sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + } + }, "node_modules/debug": { "version": "2.6.9", "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", @@ -702,6 +716,15 @@ "node": ">= 0.6" } }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/object-inspect": { "version": "1.13.3", "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.3.tgz", diff --git a/package.json b/package.json index 00ad331..a2318d5 100644 --- a/package.json +++ b/package.json @@ -3,6 +3,7 @@ "@sendgrid/mail": "^8.1.4", "body-parser": "^1.20.3", "cookie-parser": "^1.4.7", + "cors": "^2.8.5", "express": "^4.21.2", "formidable": "^3.5.2" } diff --git a/server.js b/server.js index d3bcf2b..e09af46 100644 --- a/server.js +++ b/server.js @@ -6,25 +6,54 @@ import fs from "node:fs"; import cookieParser from 'cookie-parser'; +import path from 'node:path'; +import { fileURLToPath } from "node:url"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +import cors from 'cors'; +import bodyParser from "body-parser"; +import MimeMessage from "./lib/MimeMessage.js"; +import MimeHeader from "./lib/MimeHeader.js"; + +app.use(cors()); + app.use(cookieParser()); +let state = +fs.readFileSync("state.integer", "utf-8"); +console.log(`State: ${state.toString()}`); + +// app.use((req, res, next) => { +// console.log({url: req.url, headers: req.headers}); +// req.on("data", (data) => { +// console.log(data.toString("utf8")); +// }) +// next(); +// }) + app.post('/sendgrid/ingress', (req, res) => { const form = formidable({}); form.parse(req, (err, fields, files) => { - var message = ""; - if ( fields.email ) { - if ( typeof fields.email === "string") { - message = fields.email; - } else { - message = fields.email.join(""); - } - } + var message = fields.email?.[0]; - fs.writeFileSync(`messages/${new Date().toISOString()}.eml`, message); - fs.writeFileSync(`messages/${new Date().toISOString()}.json`, JSON.stringify(fields)); + var messageObj = new MimeMessage(message); + + var envelope = JSON.parse(fields.envelope?.[0]??"{}"); + + messageObj.prependHeader(new MimeHeader(`Received: from ${req.headers["x-forwarded-for"]} by andrewpietila.com with HTTPS-API id ${state} for ${envelope.to??"unknown"}; ${new Date().toISOString()}`)); + messageObj.prependHeader(new MimeHeader(`X-Auth-Check: SPF ${fields.SPF?.[0]??"missing"}`)); + messageObj.prependHeader(new MimeHeader(`X-Auth-Check: DKIM ${fields.dkim?.[0]??"missing"}`)); + messageObj.prependHeader(new MimeHeader(`Return-Path: <${envelope.from??"unknown"}>`)); + + fs.writeFileSync(`messages/${new Date().toISOString()}.eml`, messageObj.toString()); + fs.writeFileSync(`messages/${new Date().toISOString()}-headers.json`, JSON.stringify(req.headers, null, 4)); + fs.writeFileSync(`messages/${new Date().toISOString()}.json`, JSON.stringify(fields, null, 4)); res.status(204); res.end(); + state++; + fs.writeFileSync("state.integer", state.toString(), {encoding: "utf-8"}); }) }); @@ -43,7 +72,8 @@ app.all("/.well-known/jmap", (req, res) => { // https://jmap.io/spec-core.html#the-jmap-session-resource // TODO: Authenticated app.get("/jmap/session", (req, res) => { - if ( req.cookies.authenticated == "true" ) + // TODO: HTTP Basic auth. + // if ( req.cookies.authenticated == "true" ) res.json({ "capabilities": { "urn:ietf:params:jmap:core": { @@ -60,6 +90,20 @@ app.get("/jmap/session", (req, res) => { "i;unicode-casemap" ] }, + "urn:ietf:params:jmap:mail": { + maxMailboxesPerEmail: null, + maxMailboxDepth: null, + maxSizeMailboxName: 100, + maxSizeAttachmentsPerEmail: 50_000_000, + emailQuerySortOptions: [ + "receivedAt" + ], + mayCreateTopLevelMailbox: false + }, + "urn:ietf:params:jmap:submission": { + maxDelayedSend: 0, + submissionExtensions: [] + } }, "accounts": { "andrew@andrewpietila.com": { @@ -78,10 +122,89 @@ app.get("/jmap/session", (req, res) => { "eventSourceUrl": "https://andrewpietila.com/api/jmap/eventsource/?types={types}&closeafter={closeafter}&ping={ping}", "state": "75128aab4b1b" }); - else { - res.status(404); - res.end(); + // else { + // res.status(404); + // res.end(); + // } +}); + +function isEmpty(obj) { + for (var prop in obj) { + if ( Object.prototype.hasOwnProperty.call(obj, prop) ) { + return false; + } } + + return true; +} + +app.post("/api/jmap/api/", bodyParser.json(), (req, res) => { + const responses = req.body.methodCalls.map(element => { + if (element[0] === "Identity/get" && isEmpty(element[1]) ) { + return [ "Identity/get", + { + accountId: "andrew@andrewpietila.com", + state: state.toString(), + "list": [ + { + "id": "andrew@andrewpietila.com", + "name": "Andrew Pietila", + "email": "andrew@andrewpietla.com", + "replyTo": null, + "bcc": null, + textSignature: "", + htmlSignature: "", + mayDelete: true + } + ] + }, + element[2] + ] + } else if ( element[0] === "Mailbox/get" && isEmpty(element[1]) ) { + return ["Mailbox/get", + { + accountId: "andrew@andrewpietila.com", + state: state.toString(), + list: [{ + id: "Inbox", + name: "Inbox", + parentId: null, + role: "inbox", + sortOrder: 0, + totalEmails: 0, // TODO: Index messages. + unreadEmails: 0, // TODO: Index messages. + totalThreads: 0, // TODO: Index messages. + unreadThreads: 0, // TODO: Index messages. + myRights: { + mayReadItems: true, + mayAddItems: true, + mayRemoveItems: true, + maySetSeen: true, + maySetKeyword: false, + mayCreateChild: false, // TODO: Implement child folders. + mayRename: false, // TODO: Implement renaming folders. + mayDelete: true, + maySubmit: true + }, + isSubscribed: true // TODO: Handle subscriptions. + }], + }, + element[2] + ] + } + + return [ + "error", + { + type: "unknownMethod" + }, + element[2] + ] + }); + + res.json({"methodResponses": responses, sessionState: "1"}); }) +app.use("/static", express.static(__dirname + "/static")); + app.listen(8080); \ No newline at end of file diff --git a/static/jmapjs b/static/jmapjs new file mode 160000 index 0000000..2d59257 --- /dev/null +++ b/static/jmapjs @@ -0,0 +1 @@ +Subproject commit 2d59257506c634af1c2bf2a6325cc2aac59831b6 diff --git a/static/login.html b/static/login.html new file mode 100644 index 0000000..400020a --- /dev/null +++ b/static/login.html @@ -0,0 +1,9 @@ + + + + Login + +
+ +
+ \ No newline at end of file