253 lines
No EOL
9.5 KiB
JavaScript
253 lines
No EOL
9.5 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]
|
|
]
|
|
}
|
|
}
|
|
|
|
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); |