2025-01-19 12:38:42 -06:00
import express from "express" ;
const app = express ( ) ;
import formidable from "formidable" ;
import fs from "node:fs" ;
import cookieParser from 'cookie-parser' ;
2025-01-20 00:02:31 -06:00
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" ;
2025-01-20 00:16:04 -06:00
import dns from 'node:dns' ;
2025-01-21 22:14:02 -06:00
import Knex from "knex" ;
const knex = Knex ( {
client : 'better-sqlite3' ,
connection : {
filename : './dev.sqlite3'
}
} )
2025-01-20 00:16:04 -06:00
2025-01-20 00:02:31 -06:00
app . use ( cors ( ) ) ;
2025-01-19 12:38:42 -06:00
app . use ( cookieParser ( ) ) ;
2025-01-20 00:02:31 -06:00
let state = + fs . readFileSync ( "state.integer" , "utf-8" ) ;
console . log ( ` State: ${ state . toString ( ) } ` ) ;
2025-01-21 22:14:02 -06:00
app . use ( ( req , res , next ) => {
console . log ( { url : req . url , headers : req . headers } ) ;
2025-01-20 00:02:31 -06:00
// req.on("data", (data) => {
// console.log(data.toString("utf8"));
// })
2025-01-21 22:14:02 -06:00
next ( ) ;
} )
2025-01-20 00:02:31 -06:00
2025-01-19 12:38:42 -06:00
app . post ( '/sendgrid/ingress' , ( req , res ) => {
const form = formidable ( { } ) ;
form . parse ( req , ( err , fields , files ) => {
2025-01-21 22:14:02 -06:00
var thisState = state ;
state ++ ;
fs . writeFileSync ( "state.integer" , state . toString ( ) , { encoding : "utf-8" } ) ;
2025-01-20 00:02:31 -06:00
var message = fields . email ? . [ 0 ] ;
2025-01-19 12:38:42 -06:00
2025-01-20 00:02:31 -06:00
var messageObj = new MimeMessage ( message ) ;
var envelope = JSON . parse ( fields . envelope ? . [ 0 ] ? ? "{}" ) ;
2025-01-26 23:31:23 -06:00
dns . reverse ( typeof req . headers [ "x-forwarded-for" ] === "string" ? req . headers [ "x-forwarded-for" ] : "192.0.2.0" , async ( err , dnsRes ) => {
2025-01-20 00:16:04 -06:00
if ( err )
2025-01-26 23:31:23 -06:00
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 ( ) . toString ( ) } ` ) ) ;
2025-01-20 00:16:04 -06:00
else
2025-01-26 23:31:23 -06:00
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 ( ) . toString ( ) } ` ) ) ;
2025-01-20 00:16:04 -06:00
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 ) ) ;
2025-01-21 22:14:02 -06:00
fs . writeFileSync ( ` messageStore/ ${ thisState } .eml ` , messageObj . toString ( ) ) ;
await knex ( 'messages' ) . insert ( { mailbox : "INBOX" , state : thisState , read : false } ) ;
2025-01-20 00:16:04 -06:00
res . status ( 204 ) ;
res . end ( ) ;
} )
2025-01-19 12:38:42 -06:00
} )
} ) ;
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 ) => {
2025-01-20 00:02:31 -06:00
// TODO: HTTP Basic auth.
// if ( req.cookies.authenticated == "true" )
2025-01-19 12:38:42 -06:00
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"
]
} ,
2025-01-20 00:02:31 -06:00
"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 : [ ]
}
2025-01-19 12:38:42 -06:00
} ,
"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}" ,
2025-01-21 22:14:02 -06:00
"state" : state . toString ( )
} ) ;
2025-01-20 00:02:31 -06:00
// else {
// res.status(404);
// res.end();
// }
} ) ;
function isEmpty ( obj ) {
for ( var prop in obj ) {
if ( Object . prototype . hasOwnProperty . call ( obj , prop ) ) {
return false ;
}
2025-01-19 12:38:42 -06:00
}
2025-01-20 00:02:31 -06:00
return true ;
}
2025-01-21 22:14:02 -06:00
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" ,
2025-01-20 00:02:31 -06:00
{
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 : [ {
2025-01-21 22:14:02 -06:00
id : "INBOX" ,
2025-01-20 00:02:31 -06:00
name : "Inbox" ,
parentId : null ,
role : "inbox" ,
sortOrder : 0 ,
2025-01-21 22:14:02 -06:00
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' ) ) ,
2025-01-20 00:02:31 -06:00
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 ]
]
2025-01-21 22:14:02 -06:00
} 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 ]
]
}
2025-01-20 00:02:31 -06:00
}
2025-01-22 22:51:31 -06:00
// {
// "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}
2025-01-26 23:31:23 -06:00
// messageId: new MimeMessage().getFirstHeaderOf("Message-Id").messageIdValue()
// inReplyTo: new MimeMessage().getFirstHeadderOf("In-Reply-To").inReplyToValues()
// references: new MimeMessage().getFirstHeaderOf("References").referencesValues()
2025-01-20 00:02:31 -06:00
return [
"error" ,
{
type : "unknownMethod"
} ,
element [ 2 ]
]
} ) ;
2025-01-21 22:14:02 -06:00
const responses = await Promise . all ( responsesPromise ) ;
console . log ( JSON . stringify ( { "methodResponses" : responses , sessionState : state . toString ( ) } ) ) ;
res . json ( { "methodResponses" : responses , sessionState : state . toString ( ) } ) ;
2025-01-19 12:38:42 -06:00
} )
2025-01-20 00:02:31 -06:00
app . use ( "/static" , express . static ( _ _dirname + "/static" ) ) ;
2025-01-19 12:38:42 -06:00
app . listen ( 8080 ) ;