andrewpietiladotcom/server.js

262 lines
No EOL
10 KiB
JavaScript

import express from "express";
const app = express();
import formidable from "formidable";
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";
import dns from 'node:dns';
import Knex from "knex";
const knex = Knex({
client: 'better-sqlite3',
connection: {
filename: './dev.sqlite3'
}
})
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 thisState = state;
state++;
fs.writeFileSync("state.integer", state.toString(), { encoding: "utf-8" });
var message = fields.email?.[0];
var messageObj = new MimeMessage(message);
var envelope = JSON.parse(fields.envelope?.[0]??"{}");
dns.reverse(typeof req.headers["x-forwarded-for"] === "string" ? req.headers["x-forwarded-for"] : "192.0.2.0", (err, dnsRes) => {
if (err)
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()}`));
else
messageObj.prependHeader(new MimeHeader(`Received: from ${dnsRes[0]} (${dnsRes[0]} [${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));
fs.writeFileSync(`messageStore/${thisState}.eml`, messageObj.toString());
await knex('messages').insert({mailbox: "INBOX", state: thisState, read: false});
res.status(204);
res.end();
})
})
});
app.post('/mail/login', (req, res) => {
// TODO: Actual proper login stuffs.
res.cookie("authenticated", "true", {maxAge: 86_400_000});
res.redirect("/static/jmapjs");
})
// JMAP implementation.
app.all("/.well-known/jmap", (req, res) => {
res.redirect("/jmap/session");
});
// https://jmap.io/spec-core.html#the-jmap-session-resource
// TODO: Authenticated
app.get("/jmap/session", (req, res) => {
// TODO: HTTP Basic auth.
// if ( req.cookies.authenticated == "true" )
res.json({
"capabilities": {
"urn:ietf:params:jmap:core": {
"maxSizeUpload": 50000000,
"maxConcurrentUpload": 8,
"maxSizeRequest": 10000000,
"maxConcurrentRequests": 8,
"maxCallsInRequest": 32,
"maxObjectsInGet": 256,
"maxObjectsInSet": 128,
"collationAlgorithms": [
"i;ascii-numeric",
"i;ascii-casemap",
"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": {
"name": "andrew@andrewpietila.com",
isPersonal: true,
isReadOnly: false,
accountCapabilities: {}
}
},
"primaryAccounts": {
},
"username": "andrew@andrewpietila.com",
"apiUrl": "https://andrewpietila.com/api/jmap/api/",
"downloadUrl": "https://andrewpietila.com/api/jmap/download/{accountId}/{blobId}/{name}?accept={type}",
"uploadUrl": "https://andrewpietila.com/api/jmap/upload/{accountId}/",
"eventSourceUrl": "https://andrewpietila.com/api/jmap/eventsource/?types={types}&closeafter={closeafter}&ping={ping}",
"state": state.toString()
});
// 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(), async (req, res) => {
if ( req.header("authorization") !== "SUPER SECRET KEY" ) {
res.status(401);
res.end();
return;
}
console.log(JSON.stringify(req.body));
const responsesPromise = req.body.methodCalls.map(async 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: +(await knex("messages").select().where("mailbox", "like", "INBOX").count('state')),
unreadEmails: +(await knex("messages").select().where("mailbox", "like", "INBOX").andWhere("read", "=", false).count('state')),
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]
]
} else if ( element[0] === "Email/query" ) {
if ( element[1]["filter"] && element[1]["filter"]["inMailbox"] === "INBOX" ) {
// TODO: Implement proper sorting.
const messageIds = await knex("messages").select().where("mailbox", "like", "INBOX").orderBy("id", "asc");
return ["Email/query",
{
accountId: "andrew@andrewpietila.com",
queryState: state.toString(), // TODO: Maybe not a great idea.
canCalculateChanges: false,
position: 0,
ids: messageIds.map((messageId) => messageId.state.toString()),
},
element[2]
]
}
}
// {
// "using":["urn:ietf:params:jmap:mail"],
// "methodCalls":[["Email/get",{
// "ids":["1","12","2","3","4","5","6","7","8","9"],
// "properties":["id","blobId","mailboxIds","keywords","size","receivedAt","headers","messageId","inReplyTo","references","from","to","cc","bcc","replyTo","subject","bodyStructure"]},"0"]]}
// id: state
// blobId: state
// mailboxIds: {INBOX: true} // TODO: Implement more mailboxes.
// keywords: keywords table, {keyword_column: true}
return [
"error",
{
type: "unknownMethod"
},
element[2]
]
});
const responses = await Promise.all(responsesPromise);
console.log(JSON.stringify({ "methodResponses": responses, sessionState: state.toString() }));
res.json({ "methodResponses": responses, sessionState: state.toString() });
})
app.use("/static", express.static(__dirname + "/static"));
app.listen(8080);