feat: add gmail-hook monorepo with watcher, api, and web
Ship the receipt pipeline (Livin/Grab/BRI), PocketBase spendings, PM2 ecosystem, and a proper .gitignore that excludes secrets, SQLite data, and media dumps. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,327 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { describe, it } from "node:test";
|
||||
import {
|
||||
expandWorkItems,
|
||||
listRfc822Parts,
|
||||
messageFromRfc822Part,
|
||||
} from "../src/mime.js";
|
||||
import { processFetchedMessage } from "../src/process-fetched.js";
|
||||
import { matchRule } from "../src/handlers/match-rule.js";
|
||||
import { parseMatchedMessage } from "../src/handlers/parse-matched-message.js";
|
||||
|
||||
const silentLog = {
|
||||
info() {},
|
||||
warn() {},
|
||||
error() {},
|
||||
};
|
||||
|
||||
function textPart(text, partId = "0.0") {
|
||||
return {
|
||||
partId,
|
||||
mimeType: "text/plain",
|
||||
body: {
|
||||
data: Buffer.from(text, "utf8")
|
||||
.toString("base64")
|
||||
.replace(/\+/g, "-")
|
||||
.replace(/\//g, "_")
|
||||
.replace(/=+$/, ""),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function htmlPart(html, partId = "0.1") {
|
||||
return {
|
||||
partId,
|
||||
mimeType: "text/html",
|
||||
body: {
|
||||
data: Buffer.from(html, "utf8")
|
||||
.toString("base64")
|
||||
.replace(/\+/g, "-")
|
||||
.replace(/\//g, "_")
|
||||
.replace(/=+$/, ""),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function rfc822Part({
|
||||
partId,
|
||||
filename,
|
||||
from,
|
||||
subject,
|
||||
date,
|
||||
bodyText,
|
||||
bodyHtml,
|
||||
}) {
|
||||
return {
|
||||
partId,
|
||||
mimeType: "message/rfc822",
|
||||
filename,
|
||||
parts: [
|
||||
{
|
||||
partId: `${partId}.0`,
|
||||
mimeType: "multipart/alternative",
|
||||
headers: [
|
||||
{ name: "From", value: from },
|
||||
{ name: "Subject", value: subject },
|
||||
{ name: "Date", value: date },
|
||||
],
|
||||
parts: [
|
||||
textPart(bodyText, `${partId}.0.0`),
|
||||
...(bodyHtml ? [htmlPart(bodyHtml, `${partId}.0.1`)] : []),
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
const grabRideBody = `Bike Hemat
|
||||
Hope you enjoyed your ride!
|
||||
Picked up on 13 September 2026
|
||||
Booking ID: A-TESTBOOKING1
|
||||
Total Paid
|
||||
Rp16.600
|
||||
Receipt issued by the driver Mahendra.
|
||||
Passenger
|
||||
Bintang
|
||||
`;
|
||||
|
||||
const grabFoodBody = `Selamat menikmati makanan Anda!
|
||||
TOTAL
|
||||
IDR 49000 TANGGAL | WAKTU
|
||||
11 Sep 26 18:24 +0700
|
||||
Detail Pesanan
|
||||
Jenis Kendaraan:
|
||||
GrabFood
|
||||
Diterbitkan untuk
|
||||
Bintang
|
||||
Pesanan ID
|
||||
00112712674-TESTFOOD1
|
||||
Pesanan Dari:
|
||||
Doner Kebab - Kalibata City
|
||||
Profil:
|
||||
Personal
|
||||
Detail Tagihan
|
||||
Detail Pembayaran:
|
||||
OVO Cash
|
||||
TOTAL (INCL. TAX) IDR 49000
|
||||
`;
|
||||
|
||||
describe("listRfc822Parts", () => {
|
||||
it("finds message/rfc822 attachments and does not treat outer as eml", () => {
|
||||
const payload = {
|
||||
mimeType: "multipart/mixed",
|
||||
parts: [
|
||||
{
|
||||
mimeType: "multipart/alternative",
|
||||
parts: [textPart("wrapper")],
|
||||
},
|
||||
rfc822Part({
|
||||
partId: "1",
|
||||
filename: "Your Grab E-Receipt.eml",
|
||||
from: "Grab <no-reply@grab.com>",
|
||||
subject: "Your Grab E-Receipt",
|
||||
date: "Wed, 9 Sep 2026 06:09:32 +0000",
|
||||
bodyText: grabRideBody,
|
||||
}),
|
||||
rfc822Part({
|
||||
partId: "2",
|
||||
filename: "Your Grab E-Receipt.eml",
|
||||
from: "Grab <no-reply@grab.com>",
|
||||
subject: "Your Grab E-Receipt",
|
||||
date: "Thu, 10 Sep 2026 11:28:24 +0000",
|
||||
bodyText: grabFoodBody,
|
||||
}),
|
||||
],
|
||||
};
|
||||
const parts = listRfc822Parts(payload);
|
||||
assert.equal(parts.length, 2);
|
||||
assert.equal(parts[0].filename, "Your Grab E-Receipt.eml");
|
||||
});
|
||||
});
|
||||
|
||||
describe("messageFromRfc822Part", () => {
|
||||
it("builds synthetic message with nested From/Subject/body", () => {
|
||||
const part = rfc822Part({
|
||||
partId: "1",
|
||||
filename: "Your Grab E-Receipt.eml",
|
||||
from: "Grab <no-reply@grab.com>",
|
||||
subject: "Your Grab E-Receipt",
|
||||
date: "Wed, 9 Sep 2026 06:09:32 +0000",
|
||||
bodyText: grabRideBody,
|
||||
});
|
||||
const msg = messageFromRfc822Part(part, {
|
||||
parentMessageId: "outer123",
|
||||
index: 0,
|
||||
});
|
||||
assert.equal(msg.id, "outer123#1");
|
||||
assert.equal(msg.from, "Grab <no-reply@grab.com>");
|
||||
assert.equal(msg.subject, "Your Grab E-Receipt");
|
||||
assert.match(msg.body, /Booking ID: A-TESTBOOKING1/);
|
||||
assert.equal(msg.parentMessageId, "outer123");
|
||||
assert.equal(msg.attachmentFilename, "Your Grab E-Receipt.eml");
|
||||
});
|
||||
});
|
||||
|
||||
describe("expandWorkItems", () => {
|
||||
it("returns outer only when no eml", () => {
|
||||
const outer = {
|
||||
id: "outer",
|
||||
from: "me@x.com",
|
||||
subject: "hi",
|
||||
body: "x",
|
||||
html: "",
|
||||
};
|
||||
const payload = {
|
||||
mimeType: "multipart/alternative",
|
||||
parts: [textPart("hi")],
|
||||
};
|
||||
const items = expandWorkItems(outer, payload);
|
||||
assert.equal(items.length, 1);
|
||||
assert.equal(items[0], outer);
|
||||
});
|
||||
|
||||
it("returns eml messages when attachments exist", () => {
|
||||
const outer = {
|
||||
id: "outer123",
|
||||
from: "NSRB <nasyarobby@gmail.com>",
|
||||
subject: "Test attachment",
|
||||
body: "Regards",
|
||||
html: "",
|
||||
};
|
||||
const payload = {
|
||||
mimeType: "multipart/mixed",
|
||||
parts: [
|
||||
{ mimeType: "multipart/alternative", parts: [textPart("Regards")] },
|
||||
rfc822Part({
|
||||
partId: "1",
|
||||
filename: "a.eml",
|
||||
from: "Grab <no-reply@grab.com>",
|
||||
subject: "Your Grab E-Receipt",
|
||||
date: "Wed, 9 Sep 2026 06:09:32 +0000",
|
||||
bodyText: grabRideBody,
|
||||
}),
|
||||
],
|
||||
};
|
||||
const items = expandWorkItems(outer, payload);
|
||||
assert.equal(items.length, 1);
|
||||
assert.equal(items[0].from, "Grab <no-reply@grab.com>");
|
||||
assert.equal(items[0].id, "outer123#1");
|
||||
});
|
||||
});
|
||||
|
||||
describe("processFetchedMessage fan-out", () => {
|
||||
it("processes each eml attachment and skips outer wrapper", async () => {
|
||||
const fetched = {
|
||||
notification: { emailAddress: "eleven16th@gmail.com" },
|
||||
log: silentLog,
|
||||
message: {
|
||||
id: "outer123",
|
||||
from: "NSRB <nasyarobby@gmail.com>",
|
||||
subject: "Test attachment",
|
||||
body: "Regards",
|
||||
html: "",
|
||||
},
|
||||
emlMessages: [
|
||||
{
|
||||
id: "outer123#1",
|
||||
from: "Grab <no-reply@grab.com>",
|
||||
subject: "Your Grab E-Receipt",
|
||||
date: "Wed, 9 Sep 2026 06:09:32 +0000",
|
||||
body: grabRideBody,
|
||||
html: "",
|
||||
parentMessageId: "outer123",
|
||||
},
|
||||
{
|
||||
id: "outer123#2",
|
||||
from: "Grab <no-reply@grab.com>",
|
||||
subject: "Your Grab E-Receipt",
|
||||
date: "Thu, 10 Sep 2026 11:28:24 +0000",
|
||||
body: grabFoodBody,
|
||||
html: "",
|
||||
parentMessageId: "outer123",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const results = await processFetchedMessage(fetched, [
|
||||
matchRule,
|
||||
parseMatchedMessage,
|
||||
]);
|
||||
assert.equal(results.length, 2);
|
||||
assert.equal(results[0].parsed?.kind, "grab.ereceipt.ride");
|
||||
assert.equal(results[0].parsed?.reference, "A-TESTBOOKING1");
|
||||
assert.equal(results[1].parsed?.kind, "grab.ereceipt.food");
|
||||
assert.equal(results[1].parsed?.reference, "00112712674-TESTFOOD1");
|
||||
});
|
||||
|
||||
it("processes outer message when no eml attachments", async () => {
|
||||
const fetched = {
|
||||
notification: { emailAddress: "eleven16th@gmail.com" },
|
||||
log: silentLog,
|
||||
message: {
|
||||
id: "direct1",
|
||||
from: `"Livin'" <noreply.livin@bankmandiri.co.id>`,
|
||||
subject: "Pembayaran Berhasil!",
|
||||
date: "Sun, 13 Sep 2026 14:48:36 +0700",
|
||||
body: `Halo TEST,
|
||||
Penerima
|
||||
Shop
|
||||
JAKARTA SELATAN - ID
|
||||
Tanggal 13 Sep 2026
|
||||
Jam 10:00:00 WIB
|
||||
Nominal Transaksi Rp 10.000,00
|
||||
No. Referensi 9999999999999999999`,
|
||||
html: "",
|
||||
},
|
||||
emlMessages: [],
|
||||
};
|
||||
|
||||
const results = await processFetchedMessage(fetched, [
|
||||
matchRule,
|
||||
parseMatchedMessage,
|
||||
]);
|
||||
assert.equal(results.length, 1);
|
||||
assert.equal(results[0].parsed?.kind, "livin.qr_payment.success");
|
||||
});
|
||||
});
|
||||
|
||||
describe("matchRule on synthetic eml", () => {
|
||||
it("matches Grab from nested headers without Fwd", async () => {
|
||||
const result = await matchRule({
|
||||
messageId: "outer#1",
|
||||
log: silentLog,
|
||||
message: {
|
||||
id: "outer#1",
|
||||
from: "Grab <no-reply@grab.com>",
|
||||
subject: "Your Grab E-Receipt",
|
||||
body: grabRideBody,
|
||||
html: "",
|
||||
},
|
||||
});
|
||||
assert.equal(result.pass.rule.kind, "grab.ereceipt.ride");
|
||||
assert.equal(result.pass.original.from, "Grab <no-reply@grab.com>");
|
||||
});
|
||||
});
|
||||
|
||||
describe("extractMimePartsFromRawEml", () => {
|
||||
it("pulls text/html from a simple multipart eml", async () => {
|
||||
const { extractMimePartsFromRawEml } = await import("../src/mime.js");
|
||||
const raw = [
|
||||
'Content-Type: multipart/alternative; boundary="bnd"',
|
||||
"",
|
||||
"--bnd",
|
||||
"Content-Type: text/plain; charset=UTF-8",
|
||||
"",
|
||||
"Hello plain",
|
||||
"--bnd",
|
||||
"Content-Type: text/html; charset=UTF-8",
|
||||
"",
|
||||
"<b>Hello html</b>",
|
||||
"--bnd--",
|
||||
"",
|
||||
].join("\r\n");
|
||||
const parts = extractMimePartsFromRawEml(raw);
|
||||
assert.match(parts.text, /Hello plain/);
|
||||
assert.match(parts.html, /Hello html/);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user