Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/email-markdown-html.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
---
"@plotday/twister": minor
---

Added: `markdownToHtml` (`@plotday/twister/utils/markdown-html`) — renders Plot Markdown note content to an HTML email fragment (mentions become `@Name`, GFM with `breaks`). Used by email connectors to send rich HTML bodies instead of raw Markdown as `text/plain`.
108 changes: 108 additions & 0 deletions connectors/gmail/src/gmail-api.test.ts
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,34 @@
import { describe, expect, it } from "vitest";
import {
buildNewEmailMessage,
buildReplyMessage,
stripQuotedReply,
transformGmailThread,
type AttachmentData,
type GmailMessage,
type GmailMessagePart,
type GmailThread,
} from "./gmail-api";

/** Decode the base64url raw message the Gmail send API would receive. */
function decodeRawMessage(b64url: string): string {
const b64 = b64url.replace(/-/g, "+").replace(/_/g, "/");
return Buffer.from(b64, "base64").toString("utf8");
}

/** Decode the base64 body of the first MIME part declaring `mimeType`. */
function decodeMimePart(raw: string, mimeType: string): string {
const segments = raw.split(/\r\n--[^\r\n]+(?:--)?\r\n?/);
for (const seg of segments) {
if (!seg.includes(`Content-Type: ${mimeType}`)) continue;
const blank = seg.indexOf("\r\n\r\n");
if (blank === -1) continue;
const b64 = seg.slice(blank + 4).replace(/\r\n/g, "").trim();
return Buffer.from(b64, "base64").toString("utf8");
}
return "";
}

// Gmail API returns part bodies as base64url. Encode fixtures the same way the
// real API does so `extractBody`'s atob/replace decode path is exercised.
function b64url(s: string): string {
Expand DownExpand Up@@ -171,3 +193,89 @@ describe("forwarded email body extraction", () => {
expect(reply).not.toContain("old message text");
});
});

describe("outbound MIME bodies (multipart/alternative HTML + plain)", () => {
const markdownBody =
"**Exploring burnout**\n\n" +
"Is burnout caused by too much work? I think either could work, " +
"and the key will be positioning and messaging for our first session.";

it("buildReplyMessage emits multipart/alternative with text and html parts", () => {
const raw = decodeRawMessage(
buildReplyMessage({
to: ["phil467@gmail.com"],
cc: ["kris@plot.day"],
from: "beth@plot.day",
subject: "Workshop ideas",
body: markdownBody,
messageId: "<abc@mail.gmail.com>",
references: "<root@mail.gmail.com>",
})
);

expect(raw).toContain("Content-Type: multipart/alternative");
expect(raw).toContain('Content-Type: text/plain; charset="UTF-8"');
expect(raw).toContain('Content-Type: text/html; charset="UTF-8"');

// HTML part renders markdown (no literal ** asterisks reach the reader).
const html = decodeMimePart(raw, "text/html");
expect(html).toContain("<strong>Exploring burnout</strong>");
expect(html).not.toContain("**Exploring burnout**");

// Plain part is clean text — markdown syntax stripped, sentence intact on
// one logical line (no hard mid-sentence wrap that the recipient would see).
const text = decodeMimePart(raw, "text/plain");
expect(text).toContain("Exploring burnout");
expect(text).not.toContain("**");
expect(text).toContain(
"Is burnout caused by too much work? I think either could work, and the key will be positioning and messaging for our first session."
);
});

it("buildReplyMessage with attachments nests alternative inside multipart/mixed", () => {
const attachments: AttachmentData[] = [
{
fileName: "notes.txt",
mimeType: "text/plain",
data: new TextEncoder().encode("hello"),
},
];
const raw = decodeRawMessage(
buildReplyMessage({
to: ["phil467@gmail.com"],
cc: [],
from: "beth@plot.day",
subject: "Workshop ideas",
body: markdownBody,
messageId: "<abc@mail.gmail.com>",
references: "",
attachments,
})
);

expect(raw).toContain("Content-Type: multipart/mixed");
expect(raw).toContain("Content-Type: multipart/alternative");
expect(raw).toContain('Content-Type: text/html; charset="UTF-8"');
expect(raw).toContain('Content-Disposition: attachment; filename="notes.txt"');
expect(decodeMimePart(raw, "text/html")).toContain(
"<strong>Exploring burnout</strong>"
);
});

it("buildNewEmailMessage emits multipart/alternative with text and html parts", () => {
const raw = decodeRawMessage(
buildNewEmailMessage({
to: ["phil467@gmail.com"],
from: "beth@plot.day",
subject: "Workshop ideas",
body: markdownBody,
})
);

expect(raw).toContain("Content-Type: multipart/alternative");
expect(decodeMimePart(raw, "text/html")).toContain(
"<strong>Exploring burnout</strong>"
);
expect(decodeMimePart(raw, "text/plain")).not.toContain("**");
});
});
187 changes: 101 additions & 86 deletions connectors/gmail/src/gmail-api.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -5,6 +5,8 @@ import type {
NewContact,
Action,
} from "@plotday/twister/plot";
import { markdownToPlainText } from "@plotday/twister/utils/markdown";
import { markdownToHtml } from "@plotday/twister/utils/markdown-html";


export type GmailLabel = {
Expand DownExpand Up@@ -1135,19 +1137,17 @@ export function buildNewEmailMessage(options: {
}

lines.push(`Subject: ${sanitizeHeaderValue(subject)}`);
lines.push(`Content-Type: text/plain; charset="UTF-8"`);
lines.push(""); // Empty line separates headers from body
lines.push(body);
lines.push(`MIME-Version: 1.0`);

const rawMessage = lines.join("\r\n");
// Body is a multipart/alternative (plain text + rendered HTML) so recipients
// get clean formatting instead of raw Markdown wrapped by their mail server.
const altBoundary = mimeBoundary("alt");
const rawMessage = [
...lines,
...buildAlternativeBlock(altBoundary, body),
].join("\r\n");

// Base64url encode
return btoa(
String.fromCharCode(...new TextEncoder().encode(rawMessage))
)
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=+$/, "");
return base64UrlEncodeMessage(rawMessage);
}

/**
Expand All@@ -1173,6 +1173,67 @@ function uint8ArrayToBase64Lines(bytes: Uint8Array): string {
return lines.join("\r\n");
}

/** Generate a unique MIME multipart boundary with the given role suffix. */
function mimeBoundary(role: string): string {
return `----=_PlotBoundary_${role}_${Date.now().toString(16)}`;
}

/** base64url-encode an RFC 2822 raw message for the Gmail send API. Chunked to
* avoid `String.fromCharCode(...bytes)` stack overflow on large bodies. */
function base64UrlEncodeMessage(rawMessage: string): string {
const msgBytes = new TextEncoder().encode(rawMessage);
const CHUNK = 32768;
let binary = "";
for (let i = 0; i < msgBytes.length; i += CHUNK) {
const slice = msgBytes.subarray(i, i + CHUNK);
for (let j = 0; j < slice.length; j++) {
binary += String.fromCharCode(slice[j]);
}
}
return btoa(binary)
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=+$/, "");
}

/**
* Build the lines of a `multipart/alternative` MIME entity (including its own
* `Content-Type` header) carrying the note body as two parts: a clean
* `text/plain` fallback (Markdown stripped to plain text, no hard column
* wrapping) and a rendered `text/html` part.
*
* Sending both — instead of raw Markdown as a bare `text/plain` body — stops
* the recipient's mail server from hard-wrapping prose mid-sentence and lets
* formatting (bold, links, lists) render. Both parts are base64-encoded so the
* declared `Content-Transfer-Encoding` matches the emitted bytes.
*
* The returned lines begin with the `Content-Type: multipart/alternative`
* header, so the caller can place them directly after the message headers (as
* the top-level body) or after a `multipart/mixed` boundary (nested alongside
* attachments).
*/
function buildAlternativeBlock(altBoundary: string, body: string): string[] {
const text = markdownToPlainText(body);
const htmlFragment = markdownToHtml(body);
const html = `<!DOCTYPE html>\r\n<html><body>\r\n${htmlFragment}\r\n</body></html>`;
const enc = new TextEncoder();
return [
`Content-Type: multipart/alternative; boundary="${altBoundary}"`,
"",
`--${altBoundary}`,
`Content-Type: text/plain; charset="UTF-8"`,
`Content-Transfer-Encoding: base64`,
"",
uint8ArrayToBase64Lines(enc.encode(text)),
`--${altBoundary}`,
`Content-Type: text/html; charset="UTF-8"`,
`Content-Transfer-Encoding: base64`,
"",
uint8ArrayToBase64Lines(enc.encode(html)),
`--${altBoundary}--`,
];
}

/**
* Attachment data for building MIME messages.
*/
Expand DownExpand Up@@ -1211,47 +1272,35 @@ export function buildReplyMessage(options: {
subject.startsWith("Re:") ? subject : `Re: ${subject}`
);

// Shared headers for both the attachment and no-attachment paths.
const headerLines: string[] = [`From: ${fromHeader}`, `To: ${toHeader}`];
if (cc.length > 0) headerLines.push(`Cc: ${ccHeader}`);
headerLines.push(`Subject: ${reSubject}`);
headerLines.push(`In-Reply-To: ${safeMessageId}`);
const refChain = safeReferences
? `${safeReferences} ${safeMessageId}`
: safeMessageId;
headerLines.push(`References: ${refChain}`);
headerLines.push(`MIME-Version: 1.0`);

// The body is always a multipart/alternative (plain text + rendered HTML) so
// recipients get clean formatting and MTAs don't hard-wrap raw Markdown.
const altBoundary = mimeBoundary("alt");
const altBlock = buildAlternativeBlock(altBoundary, body);

let rawMessage: string;

if (attachments && attachments.length > 0) {
// Build multipart/mixed message with text body + attachment parts
const boundary = `----=_PlotBoundary_${Date.now().toString(16)}`;

const headerLines: string[] = [
`From: ${fromHeader}`,
`To: ${toHeader}`,
];
if (cc.length > 0) headerLines.push(`Cc: ${ccHeader}`);
headerLines.push(`Subject: ${reSubject}`);
headerLines.push(`In-Reply-To: ${safeMessageId}`);
const refChain = safeReferences
? `${safeReferences} ${safeMessageId}`
: safeMessageId;
headerLines.push(`References: ${refChain}`);
headerLines.push(`MIME-Version: 1.0`);
headerLines.push(`Content-Type: multipart/mixed; boundary="${boundary}"`);
headerLines.push(""); // end of headers

// Text body part. Base64-encode the UTF-8 body so the declared
// Content-Transfer-Encoding matches the bytes we emit — inserting a raw
// 8-bit body under a `quoted-printable` (or any non-identity) declaration
// corrupts non-ASCII content and breaks strict MIME parsers.
const bodyPart = [
`--${boundary}`,
`Content-Type: text/plain; charset="UTF-8"`,
`Content-Transfer-Encoding: base64`,
"",
uint8ArrayToBase64Lines(new TextEncoder().encode(body)),
];
// Wrap the alternative body and the attachments in a multipart/mixed.
const mixBoundary = mimeBoundary("mix");

// Attachment parts
const attachmentParts: string[] = [];
for (const att of attachments) {
const b64Lines = uint8ArrayToBase64Lines(att.data);
// Encode filename for Content-Disposition
const safeFileName = att.fileName.replace(/[\r\n"]/g, "_");
attachmentParts.push(
`--${boundary}`,
`--${mixBoundary}`,
`Content-Type: ${att.mimeType}; name="${safeFileName}"`,
`Content-Transfer-Encoding: base64`,
`Content-Disposition: attachment; filename="${safeFileName}"`,
Expand All@@ -1261,52 +1310,18 @@ export function buildReplyMessage(options: {
}

rawMessage = [
headerLines.join("\r\n"),
bodyPart.join("\r\n"),
...attachmentParts.map((p) => p),
`--${boundary}--`,
...headerLines,
`Content-Type: multipart/mixed; boundary="${mixBoundary}"`,
"", // end of message headers
`--${mixBoundary}`,
...altBlock,
...attachmentParts,
`--${mixBoundary}--`,
].join("\r\n");
} else {
// Simple text-only message (existing path)
const lines: string[] = [
`From: ${fromHeader}`,
`To: ${toHeader}`,
];

if (cc.length > 0) {
lines.push(`Cc: ${ccHeader}`);
}

lines.push(`Subject: ${reSubject}`);
lines.push(`In-Reply-To: ${safeMessageId}`);

// Build References chain
const refChain = safeReferences
? `${safeReferences} ${safeMessageId}`
: safeMessageId;
lines.push(`References: ${refChain}`);

lines.push(`Content-Type: text/plain; charset="UTF-8"`);
lines.push(""); // Empty line separates headers from body
lines.push(body);

rawMessage = lines.join("\r\n");
}

// Base64url encode the entire raw message
const msgBytes = new TextEncoder().encode(rawMessage);
const CHUNK = 32768;
let binary = "";
for (let i = 0; i < msgBytes.length; i += CHUNK) {
const slice = msgBytes.subarray(i, i + CHUNK);
for (let j = 0; j < slice.length; j++) {
binary += String.fromCharCode(slice[j]);
}
// Top-level body is the multipart/alternative entity itself.
rawMessage = [...headerLines, ...altBlock].join("\r\n");
}
const encoded = btoa(binary)
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=+$/, "");

return encoded;
return base64UrlEncodeMessage(rawMessage);
}
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/email-markdown-html.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
---
"@plotday/twister": minor
---

Added: `markdownToHtml` (`@plotday/twister/utils/markdown-html`) — renders Plot Markdown note content to an HTML email fragment (mentions become `@Name`, GFM with `breaks`). Used by email connectors to send rich HTML bodies instead of raw Markdown as `text/plain`.
108 changes: 108 additions & 0 deletions connectors/gmail/src/gmail-api.test.ts
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,34 @@
import { describe, expect, it } from "vitest";
import {
buildNewEmailMessage,
buildReplyMessage,
stripQuotedReply,
transformGmailThread,
type AttachmentData,
type GmailMessage,
type GmailMessagePart,
type GmailThread,
} from "./gmail-api";

/** Decode the base64url raw message the Gmail send API would receive. */
function decodeRawMessage(b64url: string): string {
const b64 = b64url.replace(/-/g, "+").replace(/_/g, "/");
return Buffer.from(b64, "base64").toString("utf8");
}

/** Decode the base64 body of the first MIME part declaring `mimeType`. */
function decodeMimePart(raw: string, mimeType: string): string {
const segments = raw.split(/\r\n--[^\r\n]+(?:--)?\r\n?/);
for (const seg of segments) {
if (!seg.includes(`Content-Type: ${mimeType}`)) continue;
const blank = seg.indexOf("\r\n\r\n");
if (blank === -1) continue;
const b64 = seg.slice(blank + 4).replace(/\r\n/g, "").trim();
return Buffer.from(b64, "base64").toString("utf8");
}
return "";
}

// Gmail API returns part bodies as base64url. Encode fixtures the same way the
// real API does so `extractBody`'s atob/replace decode path is exercised.
function b64url(s: string): string {
Expand DownExpand Up@@ -171,3 +193,89 @@ describe("forwarded email body extraction", () => {
expect(reply).not.toContain("old message text");
});
});

describe("outbound MIME bodies (multipart/alternative HTML + plain)", () => {
const markdownBody =
"**Exploring burnout**\n\n" +
"Is burnout caused by too much work? I think either could work, " +
"and the key will be positioning and messaging for our first session.";

it("buildReplyMessage emits multipart/alternative with text and html parts", () => {
const raw = decodeRawMessage(
buildReplyMessage({
to: ["phil467@gmail.com"],
cc: ["kris@plot.day"],
from: "beth@plot.day",
subject: "Workshop ideas",
body: markdownBody,
messageId: "<abc@mail.gmail.com>",
references: "<root@mail.gmail.com>",
})
);

expect(raw).toContain("Content-Type: multipart/alternative");
expect(raw).toContain('Content-Type: text/plain; charset="UTF-8"');
expect(raw).toContain('Content-Type: text/html; charset="UTF-8"');

// HTML part renders markdown (no literal ** asterisks reach the reader).
const html = decodeMimePart(raw, "text/html");
expect(html).toContain("<strong>Exploring burnout</strong>");
expect(html).not.toContain("**Exploring burnout**");

// Plain part is clean text — markdown syntax stripped, sentence intact on
// one logical line (no hard mid-sentence wrap that the recipient would see).
const text = decodeMimePart(raw, "text/plain");
expect(text).toContain("Exploring burnout");
expect(text).not.toContain("**");
expect(text).toContain(
"Is burnout caused by too much work? I think either could work, and the key will be positioning and messaging for our first session."
);
});

it("buildReplyMessage with attachments nests alternative inside multipart/mixed", () => {
const attachments: AttachmentData[] = [
{
fileName: "notes.txt",
mimeType: "text/plain",
data: new TextEncoder().encode("hello"),
},
];
const raw = decodeRawMessage(
buildReplyMessage({
to: ["phil467@gmail.com"],
cc: [],
from: "beth@plot.day",
subject: "Workshop ideas",
body: markdownBody,
messageId: "<abc@mail.gmail.com>",
references: "",
attachments,
})
);

expect(raw).toContain("Content-Type: multipart/mixed");
expect(raw).toContain("Content-Type: multipart/alternative");
expect(raw).toContain('Content-Type: text/html; charset="UTF-8"');
expect(raw).toContain('Content-Disposition: attachment; filename="notes.txt"');
expect(decodeMimePart(raw, "text/html")).toContain(
"<strong>Exploring burnout</strong>"
);
});

it("buildNewEmailMessage emits multipart/alternative with text and html parts", () => {
const raw = decodeRawMessage(
buildNewEmailMessage({
to: ["phil467@gmail.com"],
from: "beth@plot.day",
subject: "Workshop ideas",
body: markdownBody,
})
);

expect(raw).toContain("Content-Type: multipart/alternative");
expect(decodeMimePart(raw, "text/html")).toContain(
"<strong>Exploring burnout</strong>"
);
expect(decodeMimePart(raw, "text/plain")).not.toContain("**");
});
});
187 changes: 101 additions & 86 deletions connectors/gmail/src/gmail-api.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -5,6 +5,8 @@ import type {
NewContact,
Action,
} from "@plotday/twister/plot";
import { markdownToPlainText } from "@plotday/twister/utils/markdown";
import { markdownToHtml } from "@plotday/twister/utils/markdown-html";


export type GmailLabel = {
Expand DownExpand Up@@ -1135,19 +1137,17 @@ export function buildNewEmailMessage(options: {
}

lines.push(`Subject: ${sanitizeHeaderValue(subject)}`);
lines.push(`Content-Type: text/plain; charset="UTF-8"`);
lines.push(""); // Empty line separates headers from body
lines.push(body);
lines.push(`MIME-Version: 1.0`);

const rawMessage = lines.join("\r\n");
// Body is a multipart/alternative (plain text + rendered HTML) so recipients
// get clean formatting instead of raw Markdown wrapped by their mail server.
const altBoundary = mimeBoundary("alt");
const rawMessage = [
...lines,
...buildAlternativeBlock(altBoundary, body),
].join("\r\n");

// Base64url encode
return btoa(
String.fromCharCode(...new TextEncoder().encode(rawMessage))
)
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=+$/, "");
return base64UrlEncodeMessage(rawMessage);
}

/**
Expand All@@ -1173,6 +1173,67 @@ function uint8ArrayToBase64Lines(bytes: Uint8Array): string {
return lines.join("\r\n");
}

/** Generate a unique MIME multipart boundary with the given role suffix. */
function mimeBoundary(role: string): string {
return `----=_PlotBoundary_${role}_${Date.now().toString(16)}`;
}

/** base64url-encode an RFC 2822 raw message for the Gmail send API. Chunked to
* avoid `String.fromCharCode(...bytes)` stack overflow on large bodies. */
function base64UrlEncodeMessage(rawMessage: string): string {
const msgBytes = new TextEncoder().encode(rawMessage);
const CHUNK = 32768;
let binary = "";
for (let i = 0; i < msgBytes.length; i += CHUNK) {
const slice = msgBytes.subarray(i, i + CHUNK);
for (let j = 0; j < slice.length; j++) {
binary += String.fromCharCode(slice[j]);
}
}
return btoa(binary)
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=+$/, "");
}

/**
* Build the lines of a `multipart/alternative` MIME entity (including its own
* `Content-Type` header) carrying the note body as two parts: a clean
* `text/plain` fallback (Markdown stripped to plain text, no hard column
* wrapping) and a rendered `text/html` part.
*
* Sending both — instead of raw Markdown as a bare `text/plain` body — stops
* the recipient's mail server from hard-wrapping prose mid-sentence and lets
* formatting (bold, links, lists) render. Both parts are base64-encoded so the
* declared `Content-Transfer-Encoding` matches the emitted bytes.
*
* The returned lines begin with the `Content-Type: multipart/alternative`
* header, so the caller can place them directly after the message headers (as
* the top-level body) or after a `multipart/mixed` boundary (nested alongside
* attachments).
*/
function buildAlternativeBlock(altBoundary: string, body: string): string[] {
const text = markdownToPlainText(body);
const htmlFragment = markdownToHtml(body);
const html = `<!DOCTYPE html>\r\n<html><body>\r\n${htmlFragment}\r\n</body></html>`;
const enc = new TextEncoder();
return [
`Content-Type: multipart/alternative; boundary="${altBoundary}"`,
"",
`--${altBoundary}`,
`Content-Type: text/plain; charset="UTF-8"`,
`Content-Transfer-Encoding: base64`,
"",
uint8ArrayToBase64Lines(enc.encode(text)),
`--${altBoundary}`,
`Content-Type: text/html; charset="UTF-8"`,
`Content-Transfer-Encoding: base64`,
"",
uint8ArrayToBase64Lines(enc.encode(html)),
`--${altBoundary}--`,
];
}

/**
* Attachment data for building MIME messages.
*/
Expand DownExpand Up@@ -1211,47 +1272,35 @@ export function buildReplyMessage(options: {
subject.startsWith("Re:") ? subject : `Re: ${subject}`
);

// Shared headers for both the attachment and no-attachment paths.
const headerLines: string[] = [`From: ${fromHeader}`, `To: ${toHeader}`];
if (cc.length > 0) headerLines.push(`Cc: ${ccHeader}`);
headerLines.push(`Subject: ${reSubject}`);
headerLines.push(`In-Reply-To: ${safeMessageId}`);
const refChain = safeReferences
? `${safeReferences} ${safeMessageId}`
: safeMessageId;
headerLines.push(`References: ${refChain}`);
headerLines.push(`MIME-Version: 1.0`);

// The body is always a multipart/alternative (plain text + rendered HTML) so
// recipients get clean formatting and MTAs don't hard-wrap raw Markdown.
const altBoundary = mimeBoundary("alt");
const altBlock = buildAlternativeBlock(altBoundary, body);

let rawMessage: string;

if (attachments && attachments.length > 0) {
// Build multipart/mixed message with text body + attachment parts
const boundary = `----=_PlotBoundary_${Date.now().toString(16)}`;

const headerLines: string[] = [
`From: ${fromHeader}`,
`To: ${toHeader}`,
];
if (cc.length > 0) headerLines.push(`Cc: ${ccHeader}`);
headerLines.push(`Subject: ${reSubject}`);
headerLines.push(`In-Reply-To: ${safeMessageId}`);
const refChain = safeReferences
? `${safeReferences} ${safeMessageId}`
: safeMessageId;
headerLines.push(`References: ${refChain}`);
headerLines.push(`MIME-Version: 1.0`);
headerLines.push(`Content-Type: multipart/mixed; boundary="${boundary}"`);
headerLines.push(""); // end of headers

// Text body part. Base64-encode the UTF-8 body so the declared
// Content-Transfer-Encoding matches the bytes we emit — inserting a raw
// 8-bit body under a `quoted-printable` (or any non-identity) declaration
// corrupts non-ASCII content and breaks strict MIME parsers.
const bodyPart = [
`--${boundary}`,
`Content-Type: text/plain; charset="UTF-8"`,
`Content-Transfer-Encoding: base64`,
"",
uint8ArrayToBase64Lines(new TextEncoder().encode(body)),
];
// Wrap the alternative body and the attachments in a multipart/mixed.
const mixBoundary = mimeBoundary("mix");

// Attachment parts
const attachmentParts: string[] = [];
for (const att of attachments) {
const b64Lines = uint8ArrayToBase64Lines(att.data);
// Encode filename for Content-Disposition
const safeFileName = att.fileName.replace(/[\r\n"]/g, "_");
attachmentParts.push(
`--${boundary}`,
`--${mixBoundary}`,
`Content-Type: ${att.mimeType}; name="${safeFileName}"`,
`Content-Transfer-Encoding: base64`,
`Content-Disposition: attachment; filename="${safeFileName}"`,
Expand All@@ -1261,52 +1310,18 @@ export function buildReplyMessage(options: {
}

rawMessage = [
headerLines.join("\r\n"),
bodyPart.join("\r\n"),
...attachmentParts.map((p) => p),
`--${boundary}--`,
...headerLines,
`Content-Type: multipart/mixed; boundary="${mixBoundary}"`,
"", // end of message headers
`--${mixBoundary}`,
...altBlock,
...attachmentParts,
`--${mixBoundary}--`,
].join("\r\n");
} else {
// Simple text-only message (existing path)
const lines: string[] = [
`From: ${fromHeader}`,
`To: ${toHeader}`,
];

if (cc.length > 0) {
lines.push(`Cc: ${ccHeader}`);
}

lines.push(`Subject: ${reSubject}`);
lines.push(`In-Reply-To: ${safeMessageId}`);

// Build References chain
const refChain = safeReferences
? `${safeReferences} ${safeMessageId}`
: safeMessageId;
lines.push(`References: ${refChain}`);

lines.push(`Content-Type: text/plain; charset="UTF-8"`);
lines.push(""); // Empty line separates headers from body
lines.push(body);

rawMessage = lines.join("\r\n");
}

// Base64url encode the entire raw message
const msgBytes = new TextEncoder().encode(rawMessage);
const CHUNK = 32768;
let binary = "";
for (let i = 0; i < msgBytes.length; i += CHUNK) {
const slice = msgBytes.subarray(i, i + CHUNK);
for (let j = 0; j < slice.length; j++) {
binary += String.fromCharCode(slice[j]);
}
// Top-level body is the multipart/alternative entity itself.
rawMessage = [...headerLines, ...altBlock].join("\r\n");
}
const encoded = btoa(binary)
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=+$/, "");

return encoded;
return base64UrlEncodeMessage(rawMessage);
}
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/email-markdown-html.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
---
"@plotday/twister": minor
---

Added: `markdownToHtml` (`@plotday/twister/utils/markdown-html`) — renders Plot Markdown note content to an HTML email fragment (mentions become `@Name`, GFM with `breaks`). Used by email connectors to send rich HTML bodies instead of raw Markdown as `text/plain`.
108 changes: 108 additions & 0 deletions connectors/gmail/src/gmail-api.test.ts
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,34 @@
import { describe, expect, it } from "vitest";
import {
buildNewEmailMessage,
buildReplyMessage,
stripQuotedReply,
transformGmailThread,
type AttachmentData,
type GmailMessage,
type GmailMessagePart,
type GmailThread,
} from "./gmail-api";

/** Decode the base64url raw message the Gmail send API would receive. */
function decodeRawMessage(b64url: string): string {
const b64 = b64url.replace(/-/g, "+").replace(/_/g, "/");
return Buffer.from(b64, "base64").toString("utf8");
}

/** Decode the base64 body of the first MIME part declaring `mimeType`. */
function decodeMimePart(raw: string, mimeType: string): string {
const segments = raw.split(/\r\n--[^\r\n]+(?:--)?\r\n?/);
for (const seg of segments) {
if (!seg.includes(`Content-Type: ${mimeType}`)) continue;
const blank = seg.indexOf("\r\n\r\n");
if (blank === -1) continue;
const b64 = seg.slice(blank + 4).replace(/\r\n/g, "").trim();
return Buffer.from(b64, "base64").toString("utf8");
}
return "";
}

// Gmail API returns part bodies as base64url. Encode fixtures the same way the
// real API does so `extractBody`'s atob/replace decode path is exercised.
function b64url(s: string): string {
Expand DownExpand Up@@ -171,3 +193,89 @@ describe("forwarded email body extraction", () => {
expect(reply).not.toContain("old message text");
});
});

describe("outbound MIME bodies (multipart/alternative HTML + plain)", () => {
const markdownBody =
"**Exploring burnout**\n\n" +
"Is burnout caused by too much work? I think either could work, " +
"and the key will be positioning and messaging for our first session.";

it("buildReplyMessage emits multipart/alternative with text and html parts", () => {
const raw = decodeRawMessage(
buildReplyMessage({
to: ["phil467@gmail.com"],
cc: ["kris@plot.day"],
from: "beth@plot.day",
subject: "Workshop ideas",
body: markdownBody,
messageId: "<abc@mail.gmail.com>",
references: "<root@mail.gmail.com>",
})
);

expect(raw).toContain("Content-Type: multipart/alternative");
expect(raw).toContain('Content-Type: text/plain; charset="UTF-8"');
expect(raw).toContain('Content-Type: text/html; charset="UTF-8"');

// HTML part renders markdown (no literal ** asterisks reach the reader).
const html = decodeMimePart(raw, "text/html");
expect(html).toContain("<strong>Exploring burnout</strong>");
expect(html).not.toContain("**Exploring burnout**");

// Plain part is clean text — markdown syntax stripped, sentence intact on
// one logical line (no hard mid-sentence wrap that the recipient would see).
const text = decodeMimePart(raw, "text/plain");
expect(text).toContain("Exploring burnout");
expect(text).not.toContain("**");
expect(text).toContain(
"Is burnout caused by too much work? I think either could work, and the key will be positioning and messaging for our first session."
);
});

it("buildReplyMessage with attachments nests alternative inside multipart/mixed", () => {
const attachments: AttachmentData[] = [
{
fileName: "notes.txt",
mimeType: "text/plain",
data: new TextEncoder().encode("hello"),
},
];
const raw = decodeRawMessage(
buildReplyMessage({
to: ["phil467@gmail.com"],
cc: [],
from: "beth@plot.day",
subject: "Workshop ideas",
body: markdownBody,
messageId: "<abc@mail.gmail.com>",
references: "",
attachments,
})
);

expect(raw).toContain("Content-Type: multipart/mixed");
expect(raw).toContain("Content-Type: multipart/alternative");
expect(raw).toContain('Content-Type: text/html; charset="UTF-8"');
expect(raw).toContain('Content-Disposition: attachment; filename="notes.txt"');
expect(decodeMimePart(raw, "text/html")).toContain(
"<strong>Exploring burnout</strong>"
);
});

it("buildNewEmailMessage emits multipart/alternative with text and html parts", () => {
const raw = decodeRawMessage(
buildNewEmailMessage({
to: ["phil467@gmail.com"],
from: "beth@plot.day",
subject: "Workshop ideas",
body: markdownBody,
})
);

expect(raw).toContain("Content-Type: multipart/alternative");
expect(decodeMimePart(raw, "text/html")).toContain(
"<strong>Exploring burnout</strong>"
);
expect(decodeMimePart(raw, "text/plain")).not.toContain("**");
});
});
187 changes: 101 additions & 86 deletions connectors/gmail/src/gmail-api.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -5,6 +5,8 @@ import type {
NewContact,
Action,
} from "@plotday/twister/plot";
import { markdownToPlainText } from "@plotday/twister/utils/markdown";
import { markdownToHtml } from "@plotday/twister/utils/markdown-html";


export type GmailLabel = {
Expand DownExpand Up@@ -1135,19 +1137,17 @@ export function buildNewEmailMessage(options: {
}

lines.push(`Subject: ${sanitizeHeaderValue(subject)}`);
lines.push(`Content-Type: text/plain; charset="UTF-8"`);
lines.push(""); // Empty line separates headers from body
lines.push(body);
lines.push(`MIME-Version: 1.0`);

const rawMessage = lines.join("\r\n");
// Body is a multipart/alternative (plain text + rendered HTML) so recipients
// get clean formatting instead of raw Markdown wrapped by their mail server.
const altBoundary = mimeBoundary("alt");
const rawMessage = [
...lines,
...buildAlternativeBlock(altBoundary, body),
].join("\r\n");

// Base64url encode
return btoa(
String.fromCharCode(...new TextEncoder().encode(rawMessage))
)
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=+$/, "");
return base64UrlEncodeMessage(rawMessage);
}

/**
Expand All@@ -1173,6 +1173,67 @@ function uint8ArrayToBase64Lines(bytes: Uint8Array): string {
return lines.join("\r\n");
}

/** Generate a unique MIME multipart boundary with the given role suffix. */
function mimeBoundary(role: string): string {
return `----=_PlotBoundary_${role}_${Date.now().toString(16)}`;
}

/** base64url-encode an RFC 2822 raw message for the Gmail send API. Chunked to
* avoid `String.fromCharCode(...bytes)` stack overflow on large bodies. */
function base64UrlEncodeMessage(rawMessage: string): string {
const msgBytes = new TextEncoder().encode(rawMessage);
const CHUNK = 32768;
let binary = "";
for (let i = 0; i < msgBytes.length; i += CHUNK) {
const slice = msgBytes.subarray(i, i + CHUNK);
for (let j = 0; j < slice.length; j++) {
binary += String.fromCharCode(slice[j]);
}
}
return btoa(binary)
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=+$/, "");
}

/**
* Build the lines of a `multipart/alternative` MIME entity (including its own
* `Content-Type` header) carrying the note body as two parts: a clean
* `text/plain` fallback (Markdown stripped to plain text, no hard column
* wrapping) and a rendered `text/html` part.
*
* Sending both — instead of raw Markdown as a bare `text/plain` body — stops
* the recipient's mail server from hard-wrapping prose mid-sentence and lets
* formatting (bold, links, lists) render. Both parts are base64-encoded so the
* declared `Content-Transfer-Encoding` matches the emitted bytes.
*
* The returned lines begin with the `Content-Type: multipart/alternative`
* header, so the caller can place them directly after the message headers (as
* the top-level body) or after a `multipart/mixed` boundary (nested alongside
* attachments).
*/
function buildAlternativeBlock(altBoundary: string, body: string): string[] {
const text = markdownToPlainText(body);
const htmlFragment = markdownToHtml(body);
const html = `<!DOCTYPE html>\r\n<html><body>\r\n${htmlFragment}\r\n</body></html>`;
const enc = new TextEncoder();
return [
`Content-Type: multipart/alternative; boundary="${altBoundary}"`,
"",
`--${altBoundary}`,
`Content-Type: text/plain; charset="UTF-8"`,
`Content-Transfer-Encoding: base64`,
"",
uint8ArrayToBase64Lines(enc.encode(text)),
`--${altBoundary}`,
`Content-Type: text/html; charset="UTF-8"`,
`Content-Transfer-Encoding: base64`,
"",
uint8ArrayToBase64Lines(enc.encode(html)),
`--${altBoundary}--`,
];
}

/**
* Attachment data for building MIME messages.
*/
Expand DownExpand Up@@ -1211,47 +1272,35 @@ export function buildReplyMessage(options: {
subject.startsWith("Re:") ? subject : `Re: ${subject}`
);

// Shared headers for both the attachment and no-attachment paths.
const headerLines: string[] = [`From: ${fromHeader}`, `To: ${toHeader}`];
if (cc.length > 0) headerLines.push(`Cc: ${ccHeader}`);
headerLines.push(`Subject: ${reSubject}`);
headerLines.push(`In-Reply-To: ${safeMessageId}`);
const refChain = safeReferences
? `${safeReferences} ${safeMessageId}`
: safeMessageId;
headerLines.push(`References: ${refChain}`);
headerLines.push(`MIME-Version: 1.0`);

// The body is always a multipart/alternative (plain text + rendered HTML) so
// recipients get clean formatting and MTAs don't hard-wrap raw Markdown.
const altBoundary = mimeBoundary("alt");
const altBlock = buildAlternativeBlock(altBoundary, body);

let rawMessage: string;

if (attachments && attachments.length > 0) {
// Build multipart/mixed message with text body + attachment parts
const boundary = `----=_PlotBoundary_${Date.now().toString(16)}`;

const headerLines: string[] = [
`From: ${fromHeader}`,
`To: ${toHeader}`,
];
if (cc.length > 0) headerLines.push(`Cc: ${ccHeader}`);
headerLines.push(`Subject: ${reSubject}`);
headerLines.push(`In-Reply-To: ${safeMessageId}`);
const refChain = safeReferences
? `${safeReferences} ${safeMessageId}`
: safeMessageId;
headerLines.push(`References: ${refChain}`);
headerLines.push(`MIME-Version: 1.0`);
headerLines.push(`Content-Type: multipart/mixed; boundary="${boundary}"`);
headerLines.push(""); // end of headers

// Text body part. Base64-encode the UTF-8 body so the declared
// Content-Transfer-Encoding matches the bytes we emit — inserting a raw
// 8-bit body under a `quoted-printable` (or any non-identity) declaration
// corrupts non-ASCII content and breaks strict MIME parsers.
const bodyPart = [
`--${boundary}`,
`Content-Type: text/plain; charset="UTF-8"`,
`Content-Transfer-Encoding: base64`,
"",
uint8ArrayToBase64Lines(new TextEncoder().encode(body)),
];
// Wrap the alternative body and the attachments in a multipart/mixed.
const mixBoundary = mimeBoundary("mix");

// Attachment parts
const attachmentParts: string[] = [];
for (const att of attachments) {
const b64Lines = uint8ArrayToBase64Lines(att.data);
// Encode filename for Content-Disposition
const safeFileName = att.fileName.replace(/[\r\n"]/g, "_");
attachmentParts.push(
`--${boundary}`,
`--${mixBoundary}`,
`Content-Type: ${att.mimeType}; name="${safeFileName}"`,
`Content-Transfer-Encoding: base64`,
`Content-Disposition: attachment; filename="${safeFileName}"`,
Expand All@@ -1261,52 +1310,18 @@ export function buildReplyMessage(options: {
}

rawMessage = [
headerLines.join("\r\n"),
bodyPart.join("\r\n"),
...attachmentParts.map((p) => p),
`--${boundary}--`,
...headerLines,
`Content-Type: multipart/mixed; boundary="${mixBoundary}"`,
"", // end of message headers
`--${mixBoundary}`,
...altBlock,
...attachmentParts,
`--${mixBoundary}--`,
].join("\r\n");
} else {
// Simple text-only message (existing path)
const lines: string[] = [
`From: ${fromHeader}`,
`To: ${toHeader}`,
];

if (cc.length > 0) {
lines.push(`Cc: ${ccHeader}`);
}

lines.push(`Subject: ${reSubject}`);
lines.push(`In-Reply-To: ${safeMessageId}`);

// Build References chain
const refChain = safeReferences
? `${safeReferences} ${safeMessageId}`
: safeMessageId;
lines.push(`References: ${refChain}`);

lines.push(`Content-Type: text/plain; charset="UTF-8"`);
lines.push(""); // Empty line separates headers from body
lines.push(body);

rawMessage = lines.join("\r\n");
}

// Base64url encode the entire raw message
const msgBytes = new TextEncoder().encode(rawMessage);
const CHUNK = 32768;
let binary = "";
for (let i = 0; i < msgBytes.length; i += CHUNK) {
const slice = msgBytes.subarray(i, i + CHUNK);
for (let j = 0; j < slice.length; j++) {
binary += String.fromCharCode(slice[j]);
}
// Top-level body is the multipart/alternative entity itself.
rawMessage = [...headerLines, ...altBlock].join("\r\n");
}
const encoded = btoa(binary)
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=+$/, "");

return encoded;
return base64UrlEncodeMessage(rawMessage);
}
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/email-markdown-html.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
---
"@plotday/twister": minor
---

Added: `markdownToHtml` (`@plotday/twister/utils/markdown-html`) — renders Plot Markdown note content to an HTML email fragment (mentions become `@Name`, GFM with `breaks`). Used by email connectors to send rich HTML bodies instead of raw Markdown as `text/plain`.
108 changes: 108 additions & 0 deletions connectors/gmail/src/gmail-api.test.ts
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,34 @@
import { describe, expect, it } from "vitest";
import {
buildNewEmailMessage,
buildReplyMessage,
stripQuotedReply,
transformGmailThread,
type AttachmentData,
type GmailMessage,
type GmailMessagePart,
type GmailThread,
} from "./gmail-api";

/** Decode the base64url raw message the Gmail send API would receive. */
function decodeRawMessage(b64url: string): string {
const b64 = b64url.replace(/-/g, "+").replace(/_/g, "/");
return Buffer.from(b64, "base64").toString("utf8");
}

/** Decode the base64 body of the first MIME part declaring `mimeType`. */
function decodeMimePart(raw: string, mimeType: string): string {
const segments = raw.split(/\r\n--[^\r\n]+(?:--)?\r\n?/);
for (const seg of segments) {
if (!seg.includes(`Content-Type: ${mimeType}`)) continue;
const blank = seg.indexOf("\r\n\r\n");
if (blank === -1) continue;
const b64 = seg.slice(blank + 4).replace(/\r\n/g, "").trim();
return Buffer.from(b64, "base64").toString("utf8");
}
return "";
}

// Gmail API returns part bodies as base64url. Encode fixtures the same way the
// real API does so `extractBody`'s atob/replace decode path is exercised.
function b64url(s: string): string {
Expand DownExpand Up@@ -171,3 +193,89 @@ describe("forwarded email body extraction", () => {
expect(reply).not.toContain("old message text");
});
});

describe("outbound MIME bodies (multipart/alternative HTML + plain)", () => {
const markdownBody =
"**Exploring burnout**\n\n" +
"Is burnout caused by too much work? I think either could work, " +
"and the key will be positioning and messaging for our first session.";

it("buildReplyMessage emits multipart/alternative with text and html parts", () => {
const raw = decodeRawMessage(
buildReplyMessage({
to: ["phil467@gmail.com"],
cc: ["kris@plot.day"],
from: "beth@plot.day",
subject: "Workshop ideas",
body: markdownBody,
messageId: "<abc@mail.gmail.com>",
references: "<root@mail.gmail.com>",
})
);

expect(raw).toContain("Content-Type: multipart/alternative");
expect(raw).toContain('Content-Type: text/plain; charset="UTF-8"');
expect(raw).toContain('Content-Type: text/html; charset="UTF-8"');

// HTML part renders markdown (no literal ** asterisks reach the reader).
const html = decodeMimePart(raw, "text/html");
expect(html).toContain("<strong>Exploring burnout</strong>");
expect(html).not.toContain("**Exploring burnout**");

// Plain part is clean text — markdown syntax stripped, sentence intact on
// one logical line (no hard mid-sentence wrap that the recipient would see).
const text = decodeMimePart(raw, "text/plain");
expect(text).toContain("Exploring burnout");
expect(text).not.toContain("**");
expect(text).toContain(
"Is burnout caused by too much work? I think either could work, and the key will be positioning and messaging for our first session."
);
});

it("buildReplyMessage with attachments nests alternative inside multipart/mixed", () => {
const attachments: AttachmentData[] = [
{
fileName: "notes.txt",
mimeType: "text/plain",
data: new TextEncoder().encode("hello"),
},
];
const raw = decodeRawMessage(
buildReplyMessage({
to: ["phil467@gmail.com"],
cc: [],
from: "beth@plot.day",
subject: "Workshop ideas",
body: markdownBody,
messageId: "<abc@mail.gmail.com>",
references: "",
attachments,
})
);

expect(raw).toContain("Content-Type: multipart/mixed");
expect(raw).toContain("Content-Type: multipart/alternative");
expect(raw).toContain('Content-Type: text/html; charset="UTF-8"');
expect(raw).toContain('Content-Disposition: attachment; filename="notes.txt"');
expect(decodeMimePart(raw, "text/html")).toContain(
"<strong>Exploring burnout</strong>"
);
});

it("buildNewEmailMessage emits multipart/alternative with text and html parts", () => {
const raw = decodeRawMessage(
buildNewEmailMessage({
to: ["phil467@gmail.com"],
from: "beth@plot.day",
subject: "Workshop ideas",
body: markdownBody,
})
);

expect(raw).toContain("Content-Type: multipart/alternative");
expect(decodeMimePart(raw, "text/html")).toContain(
"<strong>Exploring burnout</strong>"
);
expect(decodeMimePart(raw, "text/plain")).not.toContain("**");
});
});
187 changes: 101 additions & 86 deletions connectors/gmail/src/gmail-api.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -5,6 +5,8 @@ import type {
NewContact,
Action,
} from "@plotday/twister/plot";
import { markdownToPlainText } from "@plotday/twister/utils/markdown";
import { markdownToHtml } from "@plotday/twister/utils/markdown-html";


export type GmailLabel = {
Expand DownExpand Up@@ -1135,19 +1137,17 @@ export function buildNewEmailMessage(options: {
}

lines.push(`Subject: ${sanitizeHeaderValue(subject)}`);
lines.push(`Content-Type: text/plain; charset="UTF-8"`);
lines.push(""); // Empty line separates headers from body
lines.push(body);
lines.push(`MIME-Version: 1.0`);

const rawMessage = lines.join("\r\n");
// Body is a multipart/alternative (plain text + rendered HTML) so recipients
// get clean formatting instead of raw Markdown wrapped by their mail server.
const altBoundary = mimeBoundary("alt");
const rawMessage = [
...lines,
...buildAlternativeBlock(altBoundary, body),
].join("\r\n");

// Base64url encode
return btoa(
String.fromCharCode(...new TextEncoder().encode(rawMessage))
)
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=+$/, "");
return base64UrlEncodeMessage(rawMessage);
}

/**
Expand All@@ -1173,6 +1173,67 @@ function uint8ArrayToBase64Lines(bytes: Uint8Array): string {
return lines.join("\r\n");
}

/** Generate a unique MIME multipart boundary with the given role suffix. */
function mimeBoundary(role: string): string {
return `----=_PlotBoundary_${role}_${Date.now().toString(16)}`;
}

/** base64url-encode an RFC 2822 raw message for the Gmail send API. Chunked to
* avoid `String.fromCharCode(...bytes)` stack overflow on large bodies. */
function base64UrlEncodeMessage(rawMessage: string): string {
const msgBytes = new TextEncoder().encode(rawMessage);
const CHUNK = 32768;
let binary = "";
for (let i = 0; i < msgBytes.length; i += CHUNK) {
const slice = msgBytes.subarray(i, i + CHUNK);
for (let j = 0; j < slice.length; j++) {
binary += String.fromCharCode(slice[j]);
}
}
return btoa(binary)
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=+$/, "");
}

/**
* Build the lines of a `multipart/alternative` MIME entity (including its own
* `Content-Type` header) carrying the note body as two parts: a clean
* `text/plain` fallback (Markdown stripped to plain text, no hard column
* wrapping) and a rendered `text/html` part.
*
* Sending both — instead of raw Markdown as a bare `text/plain` body — stops
* the recipient's mail server from hard-wrapping prose mid-sentence and lets
* formatting (bold, links, lists) render. Both parts are base64-encoded so the
* declared `Content-Transfer-Encoding` matches the emitted bytes.
*
* The returned lines begin with the `Content-Type: multipart/alternative`
* header, so the caller can place them directly after the message headers (as
* the top-level body) or after a `multipart/mixed` boundary (nested alongside
* attachments).
*/
function buildAlternativeBlock(altBoundary: string, body: string): string[] {
const text = markdownToPlainText(body);
const htmlFragment = markdownToHtml(body);
const html = `<!DOCTYPE html>\r\n<html><body>\r\n${htmlFragment}\r\n</body></html>`;
const enc = new TextEncoder();
return [
`Content-Type: multipart/alternative; boundary="${altBoundary}"`,
"",
`--${altBoundary}`,
`Content-Type: text/plain; charset="UTF-8"`,
`Content-Transfer-Encoding: base64`,
"",
uint8ArrayToBase64Lines(enc.encode(text)),
`--${altBoundary}`,
`Content-Type: text/html; charset="UTF-8"`,
`Content-Transfer-Encoding: base64`,
"",
uint8ArrayToBase64Lines(enc.encode(html)),
`--${altBoundary}--`,
];
}

/**
* Attachment data for building MIME messages.
*/
Expand DownExpand Up@@ -1211,47 +1272,35 @@ export function buildReplyMessage(options: {
subject.startsWith("Re:") ? subject : `Re: ${subject}`
);

// Shared headers for both the attachment and no-attachment paths.
const headerLines: string[] = [`From: ${fromHeader}`, `To: ${toHeader}`];
if (cc.length > 0) headerLines.push(`Cc: ${ccHeader}`);
headerLines.push(`Subject: ${reSubject}`);
headerLines.push(`In-Reply-To: ${safeMessageId}`);
const refChain = safeReferences
? `${safeReferences} ${safeMessageId}`
: safeMessageId;
headerLines.push(`References: ${refChain}`);
headerLines.push(`MIME-Version: 1.0`);

// The body is always a multipart/alternative (plain text + rendered HTML) so
// recipients get clean formatting and MTAs don't hard-wrap raw Markdown.
const altBoundary = mimeBoundary("alt");
const altBlock = buildAlternativeBlock(altBoundary, body);

let rawMessage: string;

if (attachments && attachments.length > 0) {
// Build multipart/mixed message with text body + attachment parts
const boundary = `----=_PlotBoundary_${Date.now().toString(16)}`;

const headerLines: string[] = [
`From: ${fromHeader}`,
`To: ${toHeader}`,
];
if (cc.length > 0) headerLines.push(`Cc: ${ccHeader}`);
headerLines.push(`Subject: ${reSubject}`);
headerLines.push(`In-Reply-To: ${safeMessageId}`);
const refChain = safeReferences
? `${safeReferences} ${safeMessageId}`
: safeMessageId;
headerLines.push(`References: ${refChain}`);
headerLines.push(`MIME-Version: 1.0`);
headerLines.push(`Content-Type: multipart/mixed; boundary="${boundary}"`);
headerLines.push(""); // end of headers

// Text body part. Base64-encode the UTF-8 body so the declared
// Content-Transfer-Encoding matches the bytes we emit — inserting a raw
// 8-bit body under a `quoted-printable` (or any non-identity) declaration
// corrupts non-ASCII content and breaks strict MIME parsers.
const bodyPart = [
`--${boundary}`,
`Content-Type: text/plain; charset="UTF-8"`,
`Content-Transfer-Encoding: base64`,
"",
uint8ArrayToBase64Lines(new TextEncoder().encode(body)),
];
// Wrap the alternative body and the attachments in a multipart/mixed.
const mixBoundary = mimeBoundary("mix");

// Attachment parts
const attachmentParts: string[] = [];
for (const att of attachments) {
const b64Lines = uint8ArrayToBase64Lines(att.data);
// Encode filename for Content-Disposition
const safeFileName = att.fileName.replace(/[\r\n"]/g, "_");
attachmentParts.push(
`--${boundary}`,
`--${mixBoundary}`,
`Content-Type: ${att.mimeType}; name="${safeFileName}"`,
`Content-Transfer-Encoding: base64`,
`Content-Disposition: attachment; filename="${safeFileName}"`,
Expand All@@ -1261,52 +1310,18 @@ export function buildReplyMessage(options: {
}

rawMessage = [
headerLines.join("\r\n"),
bodyPart.join("\r\n"),
...attachmentParts.map((p) => p),
`--${boundary}--`,
...headerLines,
`Content-Type: multipart/mixed; boundary="${mixBoundary}"`,
"", // end of message headers
`--${mixBoundary}`,
...altBlock,
...attachmentParts,
`--${mixBoundary}--`,
].join("\r\n");
} else {
// Simple text-only message (existing path)
const lines: string[] = [
`From: ${fromHeader}`,
`To: ${toHeader}`,
];

if (cc.length > 0) {
lines.push(`Cc: ${ccHeader}`);
}

lines.push(`Subject: ${reSubject}`);
lines.push(`In-Reply-To: ${safeMessageId}`);

// Build References chain
const refChain = safeReferences
? `${safeReferences} ${safeMessageId}`
: safeMessageId;
lines.push(`References: ${refChain}`);

lines.push(`Content-Type: text/plain; charset="UTF-8"`);
lines.push(""); // Empty line separates headers from body
lines.push(body);

rawMessage = lines.join("\r\n");
}

// Base64url encode the entire raw message
const msgBytes = new TextEncoder().encode(rawMessage);
const CHUNK = 32768;
let binary = "";
for (let i = 0; i < msgBytes.length; i += CHUNK) {
const slice = msgBytes.subarray(i, i + CHUNK);
for (let j = 0; j < slice.length; j++) {
binary += String.fromCharCode(slice[j]);
}
// Top-level body is the multipart/alternative entity itself.
rawMessage = [...headerLines, ...altBlock].join("\r\n");
}
const encoded = btoa(binary)
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=+$/, "");

return encoded;
return base64UrlEncodeMessage(rawMessage);
}
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/email-markdown-html.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
---
"@plotday/twister": minor
---

Added: `markdownToHtml` (`@plotday/twister/utils/markdown-html`) — renders Plot Markdown note content to an HTML email fragment (mentions become `@Name`, GFM with `breaks`). Used by email connectors to send rich HTML bodies instead of raw Markdown as `text/plain`.
108 changes: 108 additions & 0 deletions connectors/gmail/src/gmail-api.test.ts
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,34 @@
import { describe, expect, it } from "vitest";
import {
buildNewEmailMessage,
buildReplyMessage,
stripQuotedReply,
transformGmailThread,
type AttachmentData,
type GmailMessage,
type GmailMessagePart,
type GmailThread,
} from "./gmail-api";

/** Decode the base64url raw message the Gmail send API would receive. */
function decodeRawMessage(b64url: string): string {
const b64 = b64url.replace(/-/g, "+").replace(/_/g, "/");
return Buffer.from(b64, "base64").toString("utf8");
}

/** Decode the base64 body of the first MIME part declaring `mimeType`. */
function decodeMimePart(raw: string, mimeType: string): string {
const segments = raw.split(/\r\n--[^\r\n]+(?:--)?\r\n?/);
for (const seg of segments) {
if (!seg.includes(`Content-Type: ${mimeType}`)) continue;
const blank = seg.indexOf("\r\n\r\n");
if (blank === -1) continue;
const b64 = seg.slice(blank + 4).replace(/\r\n/g, "").trim();
return Buffer.from(b64, "base64").toString("utf8");
}
return "";
}

// Gmail API returns part bodies as base64url. Encode fixtures the same way the
// real API does so `extractBody`'s atob/replace decode path is exercised.
function b64url(s: string): string {
Expand DownExpand Up@@ -171,3 +193,89 @@ describe("forwarded email body extraction", () => {
expect(reply).not.toContain("old message text");
});
});

describe("outbound MIME bodies (multipart/alternative HTML + plain)", () => {
const markdownBody =
"**Exploring burnout**\n\n" +
"Is burnout caused by too much work? I think either could work, " +
"and the key will be positioning and messaging for our first session.";

it("buildReplyMessage emits multipart/alternative with text and html parts", () => {
const raw = decodeRawMessage(
buildReplyMessage({
to: ["phil467@gmail.com"],
cc: ["kris@plot.day"],
from: "beth@plot.day",
subject: "Workshop ideas",
body: markdownBody,
messageId: "<abc@mail.gmail.com>",
references: "<root@mail.gmail.com>",
})
);

expect(raw).toContain("Content-Type: multipart/alternative");
expect(raw).toContain('Content-Type: text/plain; charset="UTF-8"');
expect(raw).toContain('Content-Type: text/html; charset="UTF-8"');

// HTML part renders markdown (no literal ** asterisks reach the reader).
const html = decodeMimePart(raw, "text/html");
expect(html).toContain("<strong>Exploring burnout</strong>");
expect(html).not.toContain("**Exploring burnout**");

// Plain part is clean text — markdown syntax stripped, sentence intact on
// one logical line (no hard mid-sentence wrap that the recipient would see).
const text = decodeMimePart(raw, "text/plain");
expect(text).toContain("Exploring burnout");
expect(text).not.toContain("**");
expect(text).toContain(
"Is burnout caused by too much work? I think either could work, and the key will be positioning and messaging for our first session."
);
});

it("buildReplyMessage with attachments nests alternative inside multipart/mixed", () => {
const attachments: AttachmentData[] = [
{
fileName: "notes.txt",
mimeType: "text/plain",
data: new TextEncoder().encode("hello"),
},
];
const raw = decodeRawMessage(
buildReplyMessage({
to: ["phil467@gmail.com"],
cc: [],
from: "beth@plot.day",
subject: "Workshop ideas",
body: markdownBody,
messageId: "<abc@mail.gmail.com>",
references: "",
attachments,
})
);

expect(raw).toContain("Content-Type: multipart/mixed");
expect(raw).toContain("Content-Type: multipart/alternative");
expect(raw).toContain('Content-Type: text/html; charset="UTF-8"');
expect(raw).toContain('Content-Disposition: attachment; filename="notes.txt"');
expect(decodeMimePart(raw, "text/html")).toContain(
"<strong>Exploring burnout</strong>"
);
});

it("buildNewEmailMessage emits multipart/alternative with text and html parts", () => {
const raw = decodeRawMessage(
buildNewEmailMessage({
to: ["phil467@gmail.com"],
from: "beth@plot.day",
subject: "Workshop ideas",
body: markdownBody,
})
);

expect(raw).toContain("Content-Type: multipart/alternative");
expect(decodeMimePart(raw, "text/html")).toContain(
"<strong>Exploring burnout</strong>"
);
expect(decodeMimePart(raw, "text/plain")).not.toContain("**");
});
});
187 changes: 101 additions & 86 deletions connectors/gmail/src/gmail-api.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -5,6 +5,8 @@ import type {
NewContact,
Action,
} from "@plotday/twister/plot";
import { markdownToPlainText } from "@plotday/twister/utils/markdown";
import { markdownToHtml } from "@plotday/twister/utils/markdown-html";


export type GmailLabel = {
Expand DownExpand Up@@ -1135,19 +1137,17 @@ export function buildNewEmailMessage(options: {
}

lines.push(`Subject: ${sanitizeHeaderValue(subject)}`);
lines.push(`Content-Type: text/plain; charset="UTF-8"`);
lines.push(""); // Empty line separates headers from body
lines.push(body);
lines.push(`MIME-Version: 1.0`);

const rawMessage = lines.join("\r\n");
// Body is a multipart/alternative (plain text + rendered HTML) so recipients
// get clean formatting instead of raw Markdown wrapped by their mail server.
const altBoundary = mimeBoundary("alt");
const rawMessage = [
...lines,
...buildAlternativeBlock(altBoundary, body),
].join("\r\n");

// Base64url encode
return btoa(
String.fromCharCode(...new TextEncoder().encode(rawMessage))
)
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=+$/, "");
return base64UrlEncodeMessage(rawMessage);
}

/**
Expand All@@ -1173,6 +1173,67 @@ function uint8ArrayToBase64Lines(bytes: Uint8Array): string {
return lines.join("\r\n");
}

/** Generate a unique MIME multipart boundary with the given role suffix. */
function mimeBoundary(role: string): string {
return `----=_PlotBoundary_${role}_${Date.now().toString(16)}`;
}

/** base64url-encode an RFC 2822 raw message for the Gmail send API. Chunked to
* avoid `String.fromCharCode(...bytes)` stack overflow on large bodies. */
function base64UrlEncodeMessage(rawMessage: string): string {
const msgBytes = new TextEncoder().encode(rawMessage);
const CHUNK = 32768;
let binary = "";
for (let i = 0; i < msgBytes.length; i += CHUNK) {
const slice = msgBytes.subarray(i, i + CHUNK);
for (let j = 0; j < slice.length; j++) {
binary += String.fromCharCode(slice[j]);
}
}
return btoa(binary)
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=+$/, "");
}

/**
* Build the lines of a `multipart/alternative` MIME entity (including its own
* `Content-Type` header) carrying the note body as two parts: a clean
* `text/plain` fallback (Markdown stripped to plain text, no hard column
* wrapping) and a rendered `text/html` part.
*
* Sending both — instead of raw Markdown as a bare `text/plain` body — stops
* the recipient's mail server from hard-wrapping prose mid-sentence and lets
* formatting (bold, links, lists) render. Both parts are base64-encoded so the
* declared `Content-Transfer-Encoding` matches the emitted bytes.
*
* The returned lines begin with the `Content-Type: multipart/alternative`
* header, so the caller can place them directly after the message headers (as
* the top-level body) or after a `multipart/mixed` boundary (nested alongside
* attachments).
*/
function buildAlternativeBlock(altBoundary: string, body: string): string[] {
const text = markdownToPlainText(body);
const htmlFragment = markdownToHtml(body);
const html = `<!DOCTYPE html>\r\n<html><body>\r\n${htmlFragment}\r\n</body></html>`;
const enc = new TextEncoder();
return [
`Content-Type: multipart/alternative; boundary="${altBoundary}"`,
"",
`--${altBoundary}`,
`Content-Type: text/plain; charset="UTF-8"`,
`Content-Transfer-Encoding: base64`,
"",
uint8ArrayToBase64Lines(enc.encode(text)),
`--${altBoundary}`,
`Content-Type: text/html; charset="UTF-8"`,
`Content-Transfer-Encoding: base64`,
"",
uint8ArrayToBase64Lines(enc.encode(html)),
`--${altBoundary}--`,
];
}

/**
* Attachment data for building MIME messages.
*/
Expand DownExpand Up@@ -1211,47 +1272,35 @@ export function buildReplyMessage(options: {
subject.startsWith("Re:") ? subject : `Re: ${subject}`
);

// Shared headers for both the attachment and no-attachment paths.
const headerLines: string[] = [`From: ${fromHeader}`, `To: ${toHeader}`];
if (cc.length > 0) headerLines.push(`Cc: ${ccHeader}`);
headerLines.push(`Subject: ${reSubject}`);
headerLines.push(`In-Reply-To: ${safeMessageId}`);
const refChain = safeReferences
? `${safeReferences} ${safeMessageId}`
: safeMessageId;
headerLines.push(`References: ${refChain}`);
headerLines.push(`MIME-Version: 1.0`);

// The body is always a multipart/alternative (plain text + rendered HTML) so
// recipients get clean formatting and MTAs don't hard-wrap raw Markdown.
const altBoundary = mimeBoundary("alt");
const altBlock = buildAlternativeBlock(altBoundary, body);

let rawMessage: string;

if (attachments && attachments.length > 0) {
// Build multipart/mixed message with text body + attachment parts
const boundary = `----=_PlotBoundary_${Date.now().toString(16)}`;

const headerLines: string[] = [
`From: ${fromHeader}`,
`To: ${toHeader}`,
];
if (cc.length > 0) headerLines.push(`Cc: ${ccHeader}`);
headerLines.push(`Subject: ${reSubject}`);
headerLines.push(`In-Reply-To: ${safeMessageId}`);
const refChain = safeReferences
? `${safeReferences} ${safeMessageId}`
: safeMessageId;
headerLines.push(`References: ${refChain}`);
headerLines.push(`MIME-Version: 1.0`);
headerLines.push(`Content-Type: multipart/mixed; boundary="${boundary}"`);
headerLines.push(""); // end of headers

// Text body part. Base64-encode the UTF-8 body so the declared
// Content-Transfer-Encoding matches the bytes we emit — inserting a raw
// 8-bit body under a `quoted-printable` (or any non-identity) declaration
// corrupts non-ASCII content and breaks strict MIME parsers.
const bodyPart = [
`--${boundary}`,
`Content-Type: text/plain; charset="UTF-8"`,
`Content-Transfer-Encoding: base64`,
"",
uint8ArrayToBase64Lines(new TextEncoder().encode(body)),
];
// Wrap the alternative body and the attachments in a multipart/mixed.
const mixBoundary = mimeBoundary("mix");

// Attachment parts
const attachmentParts: string[] = [];
for (const att of attachments) {
const b64Lines = uint8ArrayToBase64Lines(att.data);
// Encode filename for Content-Disposition
const safeFileName = att.fileName.replace(/[\r\n"]/g, "_");
attachmentParts.push(
`--${boundary}`,
`--${mixBoundary}`,
`Content-Type: ${att.mimeType}; name="${safeFileName}"`,
`Content-Transfer-Encoding: base64`,
`Content-Disposition: attachment; filename="${safeFileName}"`,
Expand All@@ -1261,52 +1310,18 @@ export function buildReplyMessage(options: {
}

rawMessage = [
headerLines.join("\r\n"),
bodyPart.join("\r\n"),
...attachmentParts.map((p) => p),
`--${boundary}--`,
...headerLines,
`Content-Type: multipart/mixed; boundary="${mixBoundary}"`,
"", // end of message headers
`--${mixBoundary}`,
...altBlock,
...attachmentParts,
`--${mixBoundary}--`,
].join("\r\n");
} else {
// Simple text-only message (existing path)
const lines: string[] = [
`From: ${fromHeader}`,
`To: ${toHeader}`,
];

if (cc.length > 0) {
lines.push(`Cc: ${ccHeader}`);
}

lines.push(`Subject: ${reSubject}`);
lines.push(`In-Reply-To: ${safeMessageId}`);

// Build References chain
const refChain = safeReferences
? `${safeReferences} ${safeMessageId}`
: safeMessageId;
lines.push(`References: ${refChain}`);

lines.push(`Content-Type: text/plain; charset="UTF-8"`);
lines.push(""); // Empty line separates headers from body
lines.push(body);

rawMessage = lines.join("\r\n");
}

// Base64url encode the entire raw message
const msgBytes = new TextEncoder().encode(rawMessage);
const CHUNK = 32768;
let binary = "";
for (let i = 0; i < msgBytes.length; i += CHUNK) {
const slice = msgBytes.subarray(i, i + CHUNK);
for (let j = 0; j < slice.length; j++) {
binary += String.fromCharCode(slice[j]);
}
// Top-level body is the multipart/alternative entity itself.
rawMessage = [...headerLines, ...altBlock].join("\r\n");
}
const encoded = btoa(binary)
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=+$/, "");

return encoded;
return base64UrlEncodeMessage(rawMessage);
}
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/email-markdown-html.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
---
"@plotday/twister": minor
---

Added: `markdownToHtml` (`@plotday/twister/utils/markdown-html`) — renders Plot Markdown note content to an HTML email fragment (mentions become `@Name`, GFM with `breaks`). Used by email connectors to send rich HTML bodies instead of raw Markdown as `text/plain`.
108 changes: 108 additions & 0 deletions connectors/gmail/src/gmail-api.test.ts
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,34 @@
import { describe, expect, it } from "vitest";
import {
buildNewEmailMessage,
buildReplyMessage,
stripQuotedReply,
transformGmailThread,
type AttachmentData,
type GmailMessage,
type GmailMessagePart,
type GmailThread,
} from "./gmail-api";

/** Decode the base64url raw message the Gmail send API would receive. */
function decodeRawMessage(b64url: string): string {
const b64 = b64url.replace(/-/g, "+").replace(/_/g, "/");
return Buffer.from(b64, "base64").toString("utf8");
}

/** Decode the base64 body of the first MIME part declaring `mimeType`. */
function decodeMimePart(raw: string, mimeType: string): string {
const segments = raw.split(/\r\n--[^\r\n]+(?:--)?\r\n?/);
for (const seg of segments) {
if (!seg.includes(`Content-Type: ${mimeType}`)) continue;
const blank = seg.indexOf("\r\n\r\n");
if (blank === -1) continue;
const b64 = seg.slice(blank + 4).replace(/\r\n/g, "").trim();
return Buffer.from(b64, "base64").toString("utf8");
}
return "";
}

// Gmail API returns part bodies as base64url. Encode fixtures the same way the
// real API does so `extractBody`'s atob/replace decode path is exercised.
function b64url(s: string): string {
Expand DownExpand Up@@ -171,3 +193,89 @@ describe("forwarded email body extraction", () => {
expect(reply).not.toContain("old message text");
});
});

describe("outbound MIME bodies (multipart/alternative HTML + plain)", () => {
const markdownBody =
"**Exploring burnout**\n\n" +
"Is burnout caused by too much work? I think either could work, " +
"and the key will be positioning and messaging for our first session.";

it("buildReplyMessage emits multipart/alternative with text and html parts", () => {
const raw = decodeRawMessage(
buildReplyMessage({
to: ["phil467@gmail.com"],
cc: ["kris@plot.day"],
from: "beth@plot.day",
subject: "Workshop ideas",
body: markdownBody,
messageId: "<abc@mail.gmail.com>",
references: "<root@mail.gmail.com>",
})
);

expect(raw).toContain("Content-Type: multipart/alternative");
expect(raw).toContain('Content-Type: text/plain; charset="UTF-8"');
expect(raw).toContain('Content-Type: text/html; charset="UTF-8"');

// HTML part renders markdown (no literal ** asterisks reach the reader).
const html = decodeMimePart(raw, "text/html");
expect(html).toContain("<strong>Exploring burnout</strong>");
expect(html).not.toContain("**Exploring burnout**");

// Plain part is clean text — markdown syntax stripped, sentence intact on
// one logical line (no hard mid-sentence wrap that the recipient would see).
const text = decodeMimePart(raw, "text/plain");
expect(text).toContain("Exploring burnout");
expect(text).not.toContain("**");
expect(text).toContain(
"Is burnout caused by too much work? I think either could work, and the key will be positioning and messaging for our first session."
);
});

it("buildReplyMessage with attachments nests alternative inside multipart/mixed", () => {
const attachments: AttachmentData[] = [
{
fileName: "notes.txt",
mimeType: "text/plain",
data: new TextEncoder().encode("hello"),
},
];
const raw = decodeRawMessage(
buildReplyMessage({
to: ["phil467@gmail.com"],
cc: [],
from: "beth@plot.day",
subject: "Workshop ideas",
body: markdownBody,
messageId: "<abc@mail.gmail.com>",
references: "",
attachments,
})
);

expect(raw).toContain("Content-Type: multipart/mixed");
expect(raw).toContain("Content-Type: multipart/alternative");
expect(raw).toContain('Content-Type: text/html; charset="UTF-8"');
expect(raw).toContain('Content-Disposition: attachment; filename="notes.txt"');
expect(decodeMimePart(raw, "text/html")).toContain(
"<strong>Exploring burnout</strong>"
);
});

it("buildNewEmailMessage emits multipart/alternative with text and html parts", () => {
const raw = decodeRawMessage(
buildNewEmailMessage({
to: ["phil467@gmail.com"],
from: "beth@plot.day",
subject: "Workshop ideas",
body: markdownBody,
})
);

expect(raw).toContain("Content-Type: multipart/alternative");
expect(decodeMimePart(raw, "text/html")).toContain(
"<strong>Exploring burnout</strong>"
);
expect(decodeMimePart(raw, "text/plain")).not.toContain("**");
});
});
187 changes: 101 additions & 86 deletions connectors/gmail/src/gmail-api.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -5,6 +5,8 @@ import type {
NewContact,
Action,
} from "@plotday/twister/plot";
import { markdownToPlainText } from "@plotday/twister/utils/markdown";
import { markdownToHtml } from "@plotday/twister/utils/markdown-html";


export type GmailLabel = {
Expand DownExpand Up@@ -1135,19 +1137,17 @@ export function buildNewEmailMessage(options: {
}

lines.push(`Subject: ${sanitizeHeaderValue(subject)}`);
lines.push(`Content-Type: text/plain; charset="UTF-8"`);
lines.push(""); // Empty line separates headers from body
lines.push(body);
lines.push(`MIME-Version: 1.0`);

const rawMessage = lines.join("\r\n");
// Body is a multipart/alternative (plain text + rendered HTML) so recipients
// get clean formatting instead of raw Markdown wrapped by their mail server.
const altBoundary = mimeBoundary("alt");
const rawMessage = [
...lines,
...buildAlternativeBlock(altBoundary, body),
].join("\r\n");

// Base64url encode
return btoa(
String.fromCharCode(...new TextEncoder().encode(rawMessage))
)
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=+$/, "");
return base64UrlEncodeMessage(rawMessage);
}

/**
Expand All@@ -1173,6 +1173,67 @@ function uint8ArrayToBase64Lines(bytes: Uint8Array): string {
return lines.join("\r\n");
}

/** Generate a unique MIME multipart boundary with the given role suffix. */
function mimeBoundary(role: string): string {
return `----=_PlotBoundary_${role}_${Date.now().toString(16)}`;
}

/** base64url-encode an RFC 2822 raw message for the Gmail send API. Chunked to
* avoid `String.fromCharCode(...bytes)` stack overflow on large bodies. */
function base64UrlEncodeMessage(rawMessage: string): string {
const msgBytes = new TextEncoder().encode(rawMessage);
const CHUNK = 32768;
let binary = "";
for (let i = 0; i < msgBytes.length; i += CHUNK) {
const slice = msgBytes.subarray(i, i + CHUNK);
for (let j = 0; j < slice.length; j++) {
binary += String.fromCharCode(slice[j]);
}
}
return btoa(binary)
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=+$/, "");
}

/**
* Build the lines of a `multipart/alternative` MIME entity (including its own
* `Content-Type` header) carrying the note body as two parts: a clean
* `text/plain` fallback (Markdown stripped to plain text, no hard column
* wrapping) and a rendered `text/html` part.
*
* Sending both — instead of raw Markdown as a bare `text/plain` body — stops
* the recipient's mail server from hard-wrapping prose mid-sentence and lets
* formatting (bold, links, lists) render. Both parts are base64-encoded so the
* declared `Content-Transfer-Encoding` matches the emitted bytes.
*
* The returned lines begin with the `Content-Type: multipart/alternative`
* header, so the caller can place them directly after the message headers (as
* the top-level body) or after a `multipart/mixed` boundary (nested alongside
* attachments).
*/
function buildAlternativeBlock(altBoundary: string, body: string): string[] {
const text = markdownToPlainText(body);
const htmlFragment = markdownToHtml(body);
const html = `<!DOCTYPE html>\r\n<html><body>\r\n${htmlFragment}\r\n</body></html>`;
const enc = new TextEncoder();
return [
`Content-Type: multipart/alternative; boundary="${altBoundary}"`,
"",
`--${altBoundary}`,
`Content-Type: text/plain; charset="UTF-8"`,
`Content-Transfer-Encoding: base64`,
"",
uint8ArrayToBase64Lines(enc.encode(text)),
`--${altBoundary}`,
`Content-Type: text/html; charset="UTF-8"`,
`Content-Transfer-Encoding: base64`,
"",
uint8ArrayToBase64Lines(enc.encode(html)),
`--${altBoundary}--`,
];
}

/**
* Attachment data for building MIME messages.
*/
Expand DownExpand Up@@ -1211,47 +1272,35 @@ export function buildReplyMessage(options: {
subject.startsWith("Re:") ? subject : `Re: ${subject}`
);

// Shared headers for both the attachment and no-attachment paths.
const headerLines: string[] = [`From: ${fromHeader}`, `To: ${toHeader}`];
if (cc.length > 0) headerLines.push(`Cc: ${ccHeader}`);
headerLines.push(`Subject: ${reSubject}`);
headerLines.push(`In-Reply-To: ${safeMessageId}`);
const refChain = safeReferences
? `${safeReferences} ${safeMessageId}`
: safeMessageId;
headerLines.push(`References: ${refChain}`);
headerLines.push(`MIME-Version: 1.0`);

// The body is always a multipart/alternative (plain text + rendered HTML) so
// recipients get clean formatting and MTAs don't hard-wrap raw Markdown.
const altBoundary = mimeBoundary("alt");
const altBlock = buildAlternativeBlock(altBoundary, body);

let rawMessage: string;

if (attachments && attachments.length > 0) {
// Build multipart/mixed message with text body + attachment parts
const boundary = `----=_PlotBoundary_${Date.now().toString(16)}`;

const headerLines: string[] = [
`From: ${fromHeader}`,
`To: ${toHeader}`,
];
if (cc.length > 0) headerLines.push(`Cc: ${ccHeader}`);
headerLines.push(`Subject: ${reSubject}`);
headerLines.push(`In-Reply-To: ${safeMessageId}`);
const refChain = safeReferences
? `${safeReferences} ${safeMessageId}`
: safeMessageId;
headerLines.push(`References: ${refChain}`);
headerLines.push(`MIME-Version: 1.0`);
headerLines.push(`Content-Type: multipart/mixed; boundary="${boundary}"`);
headerLines.push(""); // end of headers

// Text body part. Base64-encode the UTF-8 body so the declared
// Content-Transfer-Encoding matches the bytes we emit — inserting a raw
// 8-bit body under a `quoted-printable` (or any non-identity) declaration
// corrupts non-ASCII content and breaks strict MIME parsers.
const bodyPart = [
`--${boundary}`,
`Content-Type: text/plain; charset="UTF-8"`,
`Content-Transfer-Encoding: base64`,
"",
uint8ArrayToBase64Lines(new TextEncoder().encode(body)),
];
// Wrap the alternative body and the attachments in a multipart/mixed.
const mixBoundary = mimeBoundary("mix");

// Attachment parts
const attachmentParts: string[] = [];
for (const att of attachments) {
const b64Lines = uint8ArrayToBase64Lines(att.data);
// Encode filename for Content-Disposition
const safeFileName = att.fileName.replace(/[\r\n"]/g, "_");
attachmentParts.push(
`--${boundary}`,
`--${mixBoundary}`,
`Content-Type: ${att.mimeType}; name="${safeFileName}"`,
`Content-Transfer-Encoding: base64`,
`Content-Disposition: attachment; filename="${safeFileName}"`,
Expand All@@ -1261,52 +1310,18 @@ export function buildReplyMessage(options: {
}

rawMessage = [
headerLines.join("\r\n"),
bodyPart.join("\r\n"),
...attachmentParts.map((p) => p),
`--${boundary}--`,
...headerLines,
`Content-Type: multipart/mixed; boundary="${mixBoundary}"`,
"", // end of message headers
`--${mixBoundary}`,
...altBlock,
...attachmentParts,
`--${mixBoundary}--`,
].join("\r\n");
} else {
// Simple text-only message (existing path)
const lines: string[] = [
`From: ${fromHeader}`,
`To: ${toHeader}`,
];

if (cc.length > 0) {
lines.push(`Cc: ${ccHeader}`);
}

lines.push(`Subject: ${reSubject}`);
lines.push(`In-Reply-To: ${safeMessageId}`);

// Build References chain
const refChain = safeReferences
? `${safeReferences} ${safeMessageId}`
: safeMessageId;
lines.push(`References: ${refChain}`);

lines.push(`Content-Type: text/plain; charset="UTF-8"`);
lines.push(""); // Empty line separates headers from body
lines.push(body);

rawMessage = lines.join("\r\n");
}

// Base64url encode the entire raw message
const msgBytes = new TextEncoder().encode(rawMessage);
const CHUNK = 32768;
let binary = "";
for (let i = 0; i < msgBytes.length; i += CHUNK) {
const slice = msgBytes.subarray(i, i + CHUNK);
for (let j = 0; j < slice.length; j++) {
binary += String.fromCharCode(slice[j]);
}
// Top-level body is the multipart/alternative entity itself.
rawMessage = [...headerLines, ...altBlock].join("\r\n");
}
const encoded = btoa(binary)
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=+$/, "");

return encoded;
return base64UrlEncodeMessage(rawMessage);
}
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/email-markdown-html.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
---
"@plotday/twister": minor
---

Added: `markdownToHtml` (`@plotday/twister/utils/markdown-html`) — renders Plot Markdown note content to an HTML email fragment (mentions become `@Name`, GFM with `breaks`). Used by email connectors to send rich HTML bodies instead of raw Markdown as `text/plain`.
108 changes: 108 additions & 0 deletions connectors/gmail/src/gmail-api.test.ts
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,34 @@
import { describe, expect, it } from "vitest";
import {
buildNewEmailMessage,
buildReplyMessage,
stripQuotedReply,
transformGmailThread,
type AttachmentData,
type GmailMessage,
type GmailMessagePart,
type GmailThread,
} from "./gmail-api";

/** Decode the base64url raw message the Gmail send API would receive. */
function decodeRawMessage(b64url: string): string {
const b64 = b64url.replace(/-/g, "+").replace(/_/g, "/");
return Buffer.from(b64, "base64").toString("utf8");
}

/** Decode the base64 body of the first MIME part declaring `mimeType`. */
function decodeMimePart(raw: string, mimeType: string): string {
const segments = raw.split(/\r\n--[^\r\n]+(?:--)?\r\n?/);
for (const seg of segments) {
if (!seg.includes(`Content-Type: ${mimeType}`)) continue;
const blank = seg.indexOf("\r\n\r\n");
if (blank === -1) continue;
const b64 = seg.slice(blank + 4).replace(/\r\n/g, "").trim();
return Buffer.from(b64, "base64").toString("utf8");
}
return "";
}

// Gmail API returns part bodies as base64url. Encode fixtures the same way the
// real API does so `extractBody`'s atob/replace decode path is exercised.
function b64url(s: string): string {
Expand DownExpand Up@@ -171,3 +193,89 @@ describe("forwarded email body extraction", () => {
expect(reply).not.toContain("old message text");
});
});

describe("outbound MIME bodies (multipart/alternative HTML + plain)", () => {
const markdownBody =
"**Exploring burnout**\n\n" +
"Is burnout caused by too much work? I think either could work, " +
"and the key will be positioning and messaging for our first session.";

it("buildReplyMessage emits multipart/alternative with text and html parts", () => {
const raw = decodeRawMessage(
buildReplyMessage({
to: ["phil467@gmail.com"],
cc: ["kris@plot.day"],
from: "beth@plot.day",
subject: "Workshop ideas",
body: markdownBody,
messageId: "<abc@mail.gmail.com>",
references: "<root@mail.gmail.com>",
})
);

expect(raw).toContain("Content-Type: multipart/alternative");
expect(raw).toContain('Content-Type: text/plain; charset="UTF-8"');
expect(raw).toContain('Content-Type: text/html; charset="UTF-8"');

// HTML part renders markdown (no literal ** asterisks reach the reader).
const html = decodeMimePart(raw, "text/html");
expect(html).toContain("<strong>Exploring burnout</strong>");
expect(html).not.toContain("**Exploring burnout**");

// Plain part is clean text — markdown syntax stripped, sentence intact on
// one logical line (no hard mid-sentence wrap that the recipient would see).
const text = decodeMimePart(raw, "text/plain");
expect(text).toContain("Exploring burnout");
expect(text).not.toContain("**");
expect(text).toContain(
"Is burnout caused by too much work? I think either could work, and the key will be positioning and messaging for our first session."
);
});

it("buildReplyMessage with attachments nests alternative inside multipart/mixed", () => {
const attachments: AttachmentData[] = [
{
fileName: "notes.txt",
mimeType: "text/plain",
data: new TextEncoder().encode("hello"),
},
];
const raw = decodeRawMessage(
buildReplyMessage({
to: ["phil467@gmail.com"],
cc: [],
from: "beth@plot.day",
subject: "Workshop ideas",
body: markdownBody,
messageId: "<abc@mail.gmail.com>",
references: "",
attachments,
})
);

expect(raw).toContain("Content-Type: multipart/mixed");
expect(raw).toContain("Content-Type: multipart/alternative");
expect(raw).toContain('Content-Type: text/html; charset="UTF-8"');
expect(raw).toContain('Content-Disposition: attachment; filename="notes.txt"');
expect(decodeMimePart(raw, "text/html")).toContain(
"<strong>Exploring burnout</strong>"
);
});

it("buildNewEmailMessage emits multipart/alternative with text and html parts", () => {
const raw = decodeRawMessage(
buildNewEmailMessage({
to: ["phil467@gmail.com"],
from: "beth@plot.day",
subject: "Workshop ideas",
body: markdownBody,
})
);

expect(raw).toContain("Content-Type: multipart/alternative");
expect(decodeMimePart(raw, "text/html")).toContain(
"<strong>Exploring burnout</strong>"
);
expect(decodeMimePart(raw, "text/plain")).not.toContain("**");
});
});
187 changes: 101 additions & 86 deletions connectors/gmail/src/gmail-api.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -5,6 +5,8 @@ import type {
NewContact,
Action,
} from "@plotday/twister/plot";
import { markdownToPlainText } from "@plotday/twister/utils/markdown";
import { markdownToHtml } from "@plotday/twister/utils/markdown-html";


export type GmailLabel = {
Expand DownExpand Up@@ -1135,19 +1137,17 @@ export function buildNewEmailMessage(options: {
}

lines.push(`Subject: ${sanitizeHeaderValue(subject)}`);
lines.push(`Content-Type: text/plain; charset="UTF-8"`);
lines.push(""); // Empty line separates headers from body
lines.push(body);
lines.push(`MIME-Version: 1.0`);

const rawMessage = lines.join("\r\n");
// Body is a multipart/alternative (plain text + rendered HTML) so recipients
// get clean formatting instead of raw Markdown wrapped by their mail server.
const altBoundary = mimeBoundary("alt");
const rawMessage = [
...lines,
...buildAlternativeBlock(altBoundary, body),
].join("\r\n");

// Base64url encode
return btoa(
String.fromCharCode(...new TextEncoder().encode(rawMessage))
)
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=+$/, "");
return base64UrlEncodeMessage(rawMessage);
}

/**
Expand All@@ -1173,6 +1173,67 @@ function uint8ArrayToBase64Lines(bytes: Uint8Array): string {
return lines.join("\r\n");
}

/** Generate a unique MIME multipart boundary with the given role suffix. */
function mimeBoundary(role: string): string {
return `----=_PlotBoundary_${role}_${Date.now().toString(16)}`;
}

/** base64url-encode an RFC 2822 raw message for the Gmail send API. Chunked to
* avoid `String.fromCharCode(...bytes)` stack overflow on large bodies. */
function base64UrlEncodeMessage(rawMessage: string): string {
const msgBytes = new TextEncoder().encode(rawMessage);
const CHUNK = 32768;
let binary = "";
for (let i = 0; i < msgBytes.length; i += CHUNK) {
const slice = msgBytes.subarray(i, i + CHUNK);
for (let j = 0; j < slice.length; j++) {
binary += String.fromCharCode(slice[j]);
}
}
return btoa(binary)
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=+$/, "");
}

/**
* Build the lines of a `multipart/alternative` MIME entity (including its own
* `Content-Type` header) carrying the note body as two parts: a clean
* `text/plain` fallback (Markdown stripped to plain text, no hard column
* wrapping) and a rendered `text/html` part.
*
* Sending both — instead of raw Markdown as a bare `text/plain` body — stops
* the recipient's mail server from hard-wrapping prose mid-sentence and lets
* formatting (bold, links, lists) render. Both parts are base64-encoded so the
* declared `Content-Transfer-Encoding` matches the emitted bytes.
*
* The returned lines begin with the `Content-Type: multipart/alternative`
* header, so the caller can place them directly after the message headers (as
* the top-level body) or after a `multipart/mixed` boundary (nested alongside
* attachments).
*/
function buildAlternativeBlock(altBoundary: string, body: string): string[] {
const text = markdownToPlainText(body);
const htmlFragment = markdownToHtml(body);
const html = `<!DOCTYPE html>\r\n<html><body>\r\n${htmlFragment}\r\n</body></html>`;
const enc = new TextEncoder();
return [
`Content-Type: multipart/alternative; boundary="${altBoundary}"`,
"",
`--${altBoundary}`,
`Content-Type: text/plain; charset="UTF-8"`,
`Content-Transfer-Encoding: base64`,
"",
uint8ArrayToBase64Lines(enc.encode(text)),
`--${altBoundary}`,
`Content-Type: text/html; charset="UTF-8"`,
`Content-Transfer-Encoding: base64`,
"",
uint8ArrayToBase64Lines(enc.encode(html)),
`--${altBoundary}--`,
];
}

/**
* Attachment data for building MIME messages.
*/
Expand DownExpand Up@@ -1211,47 +1272,35 @@ export function buildReplyMessage(options: {
subject.startsWith("Re:") ? subject : `Re: ${subject}`
);

// Shared headers for both the attachment and no-attachment paths.
const headerLines: string[] = [`From: ${fromHeader}`, `To: ${toHeader}`];
if (cc.length > 0) headerLines.push(`Cc: ${ccHeader}`);
headerLines.push(`Subject: ${reSubject}`);
headerLines.push(`In-Reply-To: ${safeMessageId}`);
const refChain = safeReferences
? `${safeReferences} ${safeMessageId}`
: safeMessageId;
headerLines.push(`References: ${refChain}`);
headerLines.push(`MIME-Version: 1.0`);

// The body is always a multipart/alternative (plain text + rendered HTML) so
// recipients get clean formatting and MTAs don't hard-wrap raw Markdown.
const altBoundary = mimeBoundary("alt");
const altBlock = buildAlternativeBlock(altBoundary, body);

let rawMessage: string;

if (attachments && attachments.length > 0) {
// Build multipart/mixed message with text body + attachment parts
const boundary = `----=_PlotBoundary_${Date.now().toString(16)}`;

const headerLines: string[] = [
`From: ${fromHeader}`,
`To: ${toHeader}`,
];
if (cc.length > 0) headerLines.push(`Cc: ${ccHeader}`);
headerLines.push(`Subject: ${reSubject}`);
headerLines.push(`In-Reply-To: ${safeMessageId}`);
const refChain = safeReferences
? `${safeReferences} ${safeMessageId}`
: safeMessageId;
headerLines.push(`References: ${refChain}`);
headerLines.push(`MIME-Version: 1.0`);
headerLines.push(`Content-Type: multipart/mixed; boundary="${boundary}"`);
headerLines.push(""); // end of headers

// Text body part. Base64-encode the UTF-8 body so the declared
// Content-Transfer-Encoding matches the bytes we emit — inserting a raw
// 8-bit body under a `quoted-printable` (or any non-identity) declaration
// corrupts non-ASCII content and breaks strict MIME parsers.
const bodyPart = [
`--${boundary}`,
`Content-Type: text/plain; charset="UTF-8"`,
`Content-Transfer-Encoding: base64`,
"",
uint8ArrayToBase64Lines(new TextEncoder().encode(body)),
];
// Wrap the alternative body and the attachments in a multipart/mixed.
const mixBoundary = mimeBoundary("mix");

// Attachment parts
const attachmentParts: string[] = [];
for (const att of attachments) {
const b64Lines = uint8ArrayToBase64Lines(att.data);
// Encode filename for Content-Disposition
const safeFileName = att.fileName.replace(/[\r\n"]/g, "_");
attachmentParts.push(
`--${boundary}`,
`--${mixBoundary}`,
`Content-Type: ${att.mimeType}; name="${safeFileName}"`,
`Content-Transfer-Encoding: base64`,
`Content-Disposition: attachment; filename="${safeFileName}"`,
Expand All@@ -1261,52 +1310,18 @@ export function buildReplyMessage(options: {
}

rawMessage = [
headerLines.join("\r\n"),
bodyPart.join("\r\n"),
...attachmentParts.map((p) => p),
`--${boundary}--`,
...headerLines,
`Content-Type: multipart/mixed; boundary="${mixBoundary}"`,
"", // end of message headers
`--${mixBoundary}`,
...altBlock,
...attachmentParts,
`--${mixBoundary}--`,
].join("\r\n");
} else {
// Simple text-only message (existing path)
const lines: string[] = [
`From: ${fromHeader}`,
`To: ${toHeader}`,
];

if (cc.length > 0) {
lines.push(`Cc: ${ccHeader}`);
}

lines.push(`Subject: ${reSubject}`);
lines.push(`In-Reply-To: ${safeMessageId}`);

// Build References chain
const refChain = safeReferences
? `${safeReferences} ${safeMessageId}`
: safeMessageId;
lines.push(`References: ${refChain}`);

lines.push(`Content-Type: text/plain; charset="UTF-8"`);
lines.push(""); // Empty line separates headers from body
lines.push(body);

rawMessage = lines.join("\r\n");
}

// Base64url encode the entire raw message
const msgBytes = new TextEncoder().encode(rawMessage);
const CHUNK = 32768;
let binary = "";
for (let i = 0; i < msgBytes.length; i += CHUNK) {
const slice = msgBytes.subarray(i, i + CHUNK);
for (let j = 0; j < slice.length; j++) {
binary += String.fromCharCode(slice[j]);
}
// Top-level body is the multipart/alternative entity itself.
rawMessage = [...headerLines, ...altBlock].join("\r\n");
}
const encoded = btoa(binary)
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=+$/, "");

return encoded;
return base64UrlEncodeMessage(rawMessage);
}
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/email-markdown-html.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
---
"@plotday/twister": minor
---

Added: `markdownToHtml` (`@plotday/twister/utils/markdown-html`) — renders Plot Markdown note content to an HTML email fragment (mentions become `@Name`, GFM with `breaks`). Used by email connectors to send rich HTML bodies instead of raw Markdown as `text/plain`.
108 changes: 108 additions & 0 deletions connectors/gmail/src/gmail-api.test.ts
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,34 @@
import { describe, expect, it } from "vitest";
import {
buildNewEmailMessage,
buildReplyMessage,
stripQuotedReply,
transformGmailThread,
type AttachmentData,
type GmailMessage,
type GmailMessagePart,
type GmailThread,
} from "./gmail-api";

/** Decode the base64url raw message the Gmail send API would receive. */
function decodeRawMessage(b64url: string): string {
const b64 = b64url.replace(/-/g, "+").replace(/_/g, "/");
return Buffer.from(b64, "base64").toString("utf8");
}

/** Decode the base64 body of the first MIME part declaring `mimeType`. */
function decodeMimePart(raw: string, mimeType: string): string {
const segments = raw.split(/\r\n--[^\r\n]+(?:--)?\r\n?/);
for (const seg of segments) {
if (!seg.includes(`Content-Type: ${mimeType}`)) continue;
const blank = seg.indexOf("\r\n\r\n");
if (blank === -1) continue;
const b64 = seg.slice(blank + 4).replace(/\r\n/g, "").trim();
return Buffer.from(b64, "base64").toString("utf8");
}
return "";
}

// Gmail API returns part bodies as base64url. Encode fixtures the same way the
// real API does so `extractBody`'s atob/replace decode path is exercised.
function b64url(s: string): string {
Expand DownExpand Up@@ -171,3 +193,89 @@ describe("forwarded email body extraction", () => {
expect(reply).not.toContain("old message text");
});
});

describe("outbound MIME bodies (multipart/alternative HTML + plain)", () => {
const markdownBody =
"**Exploring burnout**\n\n" +
"Is burnout caused by too much work? I think either could work, " +
"and the key will be positioning and messaging for our first session.";

it("buildReplyMessage emits multipart/alternative with text and html parts", () => {
const raw = decodeRawMessage(
buildReplyMessage({
to: ["phil467@gmail.com"],
cc: ["kris@plot.day"],
from: "beth@plot.day",
subject: "Workshop ideas",
body: markdownBody,
messageId: "<abc@mail.gmail.com>",
references: "<root@mail.gmail.com>",
})
);

expect(raw).toContain("Content-Type: multipart/alternative");
expect(raw).toContain('Content-Type: text/plain; charset="UTF-8"');
expect(raw).toContain('Content-Type: text/html; charset="UTF-8"');

// HTML part renders markdown (no literal ** asterisks reach the reader).
const html = decodeMimePart(raw, "text/html");
expect(html).toContain("<strong>Exploring burnout</strong>");
expect(html).not.toContain("**Exploring burnout**");

// Plain part is clean text — markdown syntax stripped, sentence intact on
// one logical line (no hard mid-sentence wrap that the recipient would see).
const text = decodeMimePart(raw, "text/plain");
expect(text).toContain("Exploring burnout");
expect(text).not.toContain("**");
expect(text).toContain(
"Is burnout caused by too much work? I think either could work, and the key will be positioning and messaging for our first session."
);
});

it("buildReplyMessage with attachments nests alternative inside multipart/mixed", () => {
const attachments: AttachmentData[] = [
{
fileName: "notes.txt",
mimeType: "text/plain",
data: new TextEncoder().encode("hello"),
},
];
const raw = decodeRawMessage(
buildReplyMessage({
to: ["phil467@gmail.com"],
cc: [],
from: "beth@plot.day",
subject: "Workshop ideas",
body: markdownBody,
messageId: "<abc@mail.gmail.com>",
references: "",
attachments,
})
);

expect(raw).toContain("Content-Type: multipart/mixed");
expect(raw).toContain("Content-Type: multipart/alternative");
expect(raw).toContain('Content-Type: text/html; charset="UTF-8"');
expect(raw).toContain('Content-Disposition: attachment; filename="notes.txt"');
expect(decodeMimePart(raw, "text/html")).toContain(
"<strong>Exploring burnout</strong>"
);
});

it("buildNewEmailMessage emits multipart/alternative with text and html parts", () => {
const raw = decodeRawMessage(
buildNewEmailMessage({
to: ["phil467@gmail.com"],
from: "beth@plot.day",
subject: "Workshop ideas",
body: markdownBody,
})
);

expect(raw).toContain("Content-Type: multipart/alternative");
expect(decodeMimePart(raw, "text/html")).toContain(
"<strong>Exploring burnout</strong>"
);
expect(decodeMimePart(raw, "text/plain")).not.toContain("**");
});
});
187 changes: 101 additions & 86 deletions connectors/gmail/src/gmail-api.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -5,6 +5,8 @@ import type {
NewContact,
Action,
} from "@plotday/twister/plot";
import { markdownToPlainText } from "@plotday/twister/utils/markdown";
import { markdownToHtml } from "@plotday/twister/utils/markdown-html";


export type GmailLabel = {
Expand DownExpand Up@@ -1135,19 +1137,17 @@ export function buildNewEmailMessage(options: {
}

lines.push(`Subject: ${sanitizeHeaderValue(subject)}`);
lines.push(`Content-Type: text/plain; charset="UTF-8"`);
lines.push(""); // Empty line separates headers from body
lines.push(body);
lines.push(`MIME-Version: 1.0`);

const rawMessage = lines.join("\r\n");
// Body is a multipart/alternative (plain text + rendered HTML) so recipients
// get clean formatting instead of raw Markdown wrapped by their mail server.
const altBoundary = mimeBoundary("alt");
const rawMessage = [
...lines,
...buildAlternativeBlock(altBoundary, body),
].join("\r\n");

// Base64url encode
return btoa(
String.fromCharCode(...new TextEncoder().encode(rawMessage))
)
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=+$/, "");
return base64UrlEncodeMessage(rawMessage);
}

/**
Expand All@@ -1173,6 +1173,67 @@ function uint8ArrayToBase64Lines(bytes: Uint8Array): string {
return lines.join("\r\n");
}

/** Generate a unique MIME multipart boundary with the given role suffix. */
function mimeBoundary(role: string): string {
return `----=_PlotBoundary_${role}_${Date.now().toString(16)}`;
}

/** base64url-encode an RFC 2822 raw message for the Gmail send API. Chunked to
* avoid `String.fromCharCode(...bytes)` stack overflow on large bodies. */
function base64UrlEncodeMessage(rawMessage: string): string {
const msgBytes = new TextEncoder().encode(rawMessage);
const CHUNK = 32768;
let binary = "";
for (let i = 0; i < msgBytes.length; i += CHUNK) {
const slice = msgBytes.subarray(i, i + CHUNK);
for (let j = 0; j < slice.length; j++) {
binary += String.fromCharCode(slice[j]);
}
}
return btoa(binary)
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=+$/, "");
}

/**
* Build the lines of a `multipart/alternative` MIME entity (including its own
* `Content-Type` header) carrying the note body as two parts: a clean
* `text/plain` fallback (Markdown stripped to plain text, no hard column
* wrapping) and a rendered `text/html` part.
*
* Sending both — instead of raw Markdown as a bare `text/plain` body — stops
* the recipient's mail server from hard-wrapping prose mid-sentence and lets
* formatting (bold, links, lists) render. Both parts are base64-encoded so the
* declared `Content-Transfer-Encoding` matches the emitted bytes.
*
* The returned lines begin with the `Content-Type: multipart/alternative`
* header, so the caller can place them directly after the message headers (as
* the top-level body) or after a `multipart/mixed` boundary (nested alongside
* attachments).
*/
function buildAlternativeBlock(altBoundary: string, body: string): string[] {
const text = markdownToPlainText(body);
const htmlFragment = markdownToHtml(body);
const html = `<!DOCTYPE html>\r\n<html><body>\r\n${htmlFragment}\r\n</body></html>`;
const enc = new TextEncoder();
return [
`Content-Type: multipart/alternative; boundary="${altBoundary}"`,
"",
`--${altBoundary}`,
`Content-Type: text/plain; charset="UTF-8"`,
`Content-Transfer-Encoding: base64`,
"",
uint8ArrayToBase64Lines(enc.encode(text)),
`--${altBoundary}`,
`Content-Type: text/html; charset="UTF-8"`,
`Content-Transfer-Encoding: base64`,
"",
uint8ArrayToBase64Lines(enc.encode(html)),
`--${altBoundary}--`,
];
}

/**
* Attachment data for building MIME messages.
*/
Expand DownExpand Up@@ -1211,47 +1272,35 @@ export function buildReplyMessage(options: {
subject.startsWith("Re:") ? subject : `Re: ${subject}`
);

// Shared headers for both the attachment and no-attachment paths.
const headerLines: string[] = [`From: ${fromHeader}`, `To: ${toHeader}`];
if (cc.length > 0) headerLines.push(`Cc: ${ccHeader}`);
headerLines.push(`Subject: ${reSubject}`);
headerLines.push(`In-Reply-To: ${safeMessageId}`);
const refChain = safeReferences
? `${safeReferences} ${safeMessageId}`
: safeMessageId;
headerLines.push(`References: ${refChain}`);
headerLines.push(`MIME-Version: 1.0`);

// The body is always a multipart/alternative (plain text + rendered HTML) so
// recipients get clean formatting and MTAs don't hard-wrap raw Markdown.
const altBoundary = mimeBoundary("alt");
const altBlock = buildAlternativeBlock(altBoundary, body);

let rawMessage: string;

if (attachments && attachments.length > 0) {
// Build multipart/mixed message with text body + attachment parts
const boundary = `----=_PlotBoundary_${Date.now().toString(16)}`;

const headerLines: string[] = [
`From: ${fromHeader}`,
`To: ${toHeader}`,
];
if (cc.length > 0) headerLines.push(`Cc: ${ccHeader}`);
headerLines.push(`Subject: ${reSubject}`);
headerLines.push(`In-Reply-To: ${safeMessageId}`);
const refChain = safeReferences
? `${safeReferences} ${safeMessageId}`
: safeMessageId;
headerLines.push(`References: ${refChain}`);
headerLines.push(`MIME-Version: 1.0`);
headerLines.push(`Content-Type: multipart/mixed; boundary="${boundary}"`);
headerLines.push(""); // end of headers

// Text body part. Base64-encode the UTF-8 body so the declared
// Content-Transfer-Encoding matches the bytes we emit — inserting a raw
// 8-bit body under a `quoted-printable` (or any non-identity) declaration
// corrupts non-ASCII content and breaks strict MIME parsers.
const bodyPart = [
`--${boundary}`,
`Content-Type: text/plain; charset="UTF-8"`,
`Content-Transfer-Encoding: base64`,
"",
uint8ArrayToBase64Lines(new TextEncoder().encode(body)),
];
// Wrap the alternative body and the attachments in a multipart/mixed.
const mixBoundary = mimeBoundary("mix");

// Attachment parts
const attachmentParts: string[] = [];
for (const att of attachments) {
const b64Lines = uint8ArrayToBase64Lines(att.data);
// Encode filename for Content-Disposition
const safeFileName = att.fileName.replace(/[\r\n"]/g, "_");
attachmentParts.push(
`--${boundary}`,
`--${mixBoundary}`,
`Content-Type: ${att.mimeType}; name="${safeFileName}"`,
`Content-Transfer-Encoding: base64`,
`Content-Disposition: attachment; filename="${safeFileName}"`,
Expand All@@ -1261,52 +1310,18 @@ export function buildReplyMessage(options: {
}

rawMessage = [
headerLines.join("\r\n"),
bodyPart.join("\r\n"),
...attachmentParts.map((p) => p),
`--${boundary}--`,
...headerLines,
`Content-Type: multipart/mixed; boundary="${mixBoundary}"`,
"", // end of message headers
`--${mixBoundary}`,
...altBlock,
...attachmentParts,
`--${mixBoundary}--`,
].join("\r\n");
} else {
// Simple text-only message (existing path)
const lines: string[] = [
`From: ${fromHeader}`,
`To: ${toHeader}`,
];

if (cc.length > 0) {
lines.push(`Cc: ${ccHeader}`);
}

lines.push(`Subject: ${reSubject}`);
lines.push(`In-Reply-To: ${safeMessageId}`);

// Build References chain
const refChain = safeReferences
? `${safeReferences} ${safeMessageId}`
: safeMessageId;
lines.push(`References: ${refChain}`);

lines.push(`Content-Type: text/plain; charset="UTF-8"`);
lines.push(""); // Empty line separates headers from body
lines.push(body);

rawMessage = lines.join("\r\n");
}

// Base64url encode the entire raw message
const msgBytes = new TextEncoder().encode(rawMessage);
const CHUNK = 32768;
let binary = "";
for (let i = 0; i < msgBytes.length; i += CHUNK) {
const slice = msgBytes.subarray(i, i + CHUNK);
for (let j = 0; j < slice.length; j++) {
binary += String.fromCharCode(slice[j]);
}
// Top-level body is the multipart/alternative entity itself.
rawMessage = [...headerLines, ...altBlock].join("\r\n");
}
const encoded = btoa(binary)
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=+$/, "");

return encoded;
return base64UrlEncodeMessage(rawMessage);
}
Loading
Loading