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/forward-a-note.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
---
"@plotday/twister": minor
---

Added: `LinkTypeConfig.supportsForward` and `CreateLinkDraft.forward` so connectors can perform native forwards of existing items (e.g. email forwards carrying the original message and attachments) when a user forwards a note.
4 changes: 4 additions & 0 deletions connectors/gmail/src/channels.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -44,6 +44,10 @@ export const GMAIL_LINK_TYPES: LinkTypeConfig[] = [
compose: {
targets: "addresses" as const,
},
// onCreateLink reconstructs a real Gmail forward (original message +
// attachments) when `CreateLinkDraft.forward` is set, so the runtime
// should route native forwards here instead of the blockquote fallback.
supportsForward: true,
},
];

Expand Down
76 changes: 76 additions & 0 deletions connectors/gmail/src/gmail-api.test.ts
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import {
GmailApi,
buildForwardMessage,
buildNewEmailMessage,
buildReplyMessage,
stripQuotedReply,
Expand DownExpand Up@@ -319,3 +320,78 @@ describe("outbound MIME bodies (multipart/alternative HTML + plain)", () => {
expect(decodeMimePart(raw, "text/plain")).not.toContain("**");
});
});

describe("buildForwardMessage", () => {
it("uses a Fwd: subject and includes the quoted original + forwarder message", () => {
const raw = decodeRawMessage(
buildForwardMessage({
to: ["bob@example.com"],
cc: [],
from: "me@example.com",
subject: "Q3 budget",
body: "See below.",
originalHeader: "From: Alice <alice@example.com>\nSubject: Q3 budget",
originalBody: "Let's meet Thursday.",
})
);
expect(raw).toContain("Subject: Fwd: Q3 budget");
expect(raw).not.toContain("In-Reply-To:");

// Body content lives base64-encoded inside the multipart/alternative
// plain-text part, so decode that part rather than scanning the raw
// (still-encoded) message for the literal text.
const text = decodeMimePart(raw, "text/plain");
expect(text).toContain("See below.");
expect(text).toContain("Let's meet Thursday.");
});

it("keeps an existing Fwd: prefix instead of doubling it", () => {
const raw = decodeRawMessage(
buildForwardMessage({
to: ["b@x.com"],
cc: [],
from: "m@x.com",
subject: "Fwd: hi",
body: "",
originalHeader: "From: a@x.com",
originalBody: "hi",
})
);
expect(raw).toContain("Subject: Fwd: hi");
expect(raw).not.toContain("Fwd: Fwd:");
});

it("emits a Bcc header without exposing bcc addresses in To/Cc", () => {
const raw = decodeRawMessage(
buildForwardMessage({
to: ["bob@example.com"],
cc: ["carol@example.com"],
bcc: ["dave@example.com"],
from: "me@example.com",
subject: "Q3 budget",
body: "",
originalHeader: "From: Alice <alice@example.com>",
originalBody: "hi",
})
);
expect(raw).toContain("Bcc: dave@example.com");
expect(raw).toContain("To: bob@example.com");
expect(raw).not.toContain("To: bob@example.com, dave@example.com");
expect(raw).not.toContain("Cc: carol@example.com, dave@example.com");
});

it("omits the Bcc header entirely when there are no bcc recipients", () => {
const raw = decodeRawMessage(
buildForwardMessage({
to: ["bob@example.com"],
cc: [],
from: "me@example.com",
subject: "Q3 budget",
body: "",
originalHeader: "From: Alice <alice@example.com>",
originalBody: "hi",
})
);
expect(raw).not.toContain("Bcc:");
});
});
130 changes: 129 additions & 1 deletion connectors/gmail/src/gmail-api.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -283,6 +283,21 @@ export class GmailApi {
return data as GmailThread;
}

/**
* Fetches a single message by id. Used to source a native forward (see
* `buildForwardMessage`), where the caller has a message id — the note
* `key` a Gmail-backed note carries — rather than a thread id.
*/
public async getMessage(
messageId: string,
format: "full" | "minimal" | "metadata" | "raw" = "full"
): Promise<GmailMessage> {
const data = await this.call(`/messages/${messageId}`, {
params: { format },
});
return data as GmailMessage;
}

public async setupWatch(
topicName: string,
labelId?: string
Expand DownExpand Up@@ -584,7 +599,7 @@ export function isFromAddressRewritten(
* Extracts the body from a Gmail message (handles multipart messages).
* Returns raw content with its type so HTML can be converted server-side.
*/
function extractBody(part: GmailMessagePart): { content: string; contentType: "text" | "html" } {
export function extractBody(part: GmailMessagePart): { content: string; contentType: "text" | "html" } {
// Prefer HTML over plain text — server-side conversion produces cleaner output.
// Search the WHOLE MIME tree, not just immediate children: forwarded messages
// nest the real body inside a `message/rfc822` part (or a deeper multipart),
Expand DownExpand Up@@ -1457,3 +1472,116 @@ export function buildReplyMessage(options: {

return base64UrlEncodeMessage(rawMessage);
}

/**
* Builds an RFC 2822 forward of an existing Gmail message. Gmail has no
* native "forward" endpoint — a forward is just a new message sent via
* `messages.send`. Unlike a reply, a forward starts a NEW thread (no
* `In-Reply-To` / `References`). The body is the forwarder's own message
* followed by a standard quoted-original attribution block; the original
* message's attachments are re-attached.
* Returns the base64url-encoded raw message string for the Gmail API.
*/
export function buildForwardMessage(options: {
to: string[];
cc: string[];
/**
* Optional — a forward's recipients come from the same role-tagged picker
* as a compose, so a bcc-role recipient can appear here. Defaults to none.
* Gmail's send API delivers to Bcc recipients and strips the Bcc header
* from the copy other recipients receive (same as `buildNewEmailMessage`),
* so listing them here does not expose them.
*/
bcc?: string[];
from: string;
subject: string;
body: string;
originalHeader: string; // e.g. "From: … \n Date: … \n Subject: … \n To: …"
originalBody: string; // the original message's text/markdown body
attachments?: AttachmentData[];
}): string {
const {
to,
cc,
bcc = [],
from,
subject,
body,
originalHeader,
originalBody,
attachments,
} = options;

// Sanitize every value interpolated into a header to prevent CRLF header
// injection (RFC 5322) via attacker-controlled subjects or addresses.
const fromHeader = sanitizeHeaderValue(from);
const toHeader = to.map(sanitizeHeaderValue).join(", ");
const ccHeader = cc.map(sanitizeHeaderValue).join(", ");
const bccHeader = bcc.map(sanitizeHeaderValue).join(", ");

// Ensure subject has a "Fwd:" prefix, without doubling an existing one.
const fwdSubject = sanitizeHeaderValue(
subject.startsWith("Fwd:") ? subject : `Fwd: ${subject}`
);

// A forward starts a new thread, so — unlike buildReplyMessage — there is
// no In-Reply-To / References header here.
const headerLines: string[] = [`From: ${fromHeader}`, `To: ${toHeader}`];
if (cc.length > 0) headerLines.push(`Cc: ${ccHeader}`);
if (bcc.length > 0) headerLines.push(`Bcc: ${bccHeader}`);
headerLines.push(`Subject: ${fwdSubject}`);
headerLines.push(`MIME-Version: 1.0`);

// Compose the visible body: the forwarder's own message (if any) on top of
// a standard quoted-original attribution block.
const quotedOriginal = [
"---------- Forwarded message ----------",
originalHeader,
"",
originalBody,
].join("\n");
const composed = body.length > 0 ? `${body}\n\n${quotedOriginal}` : quotedOriginal;

// 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, composed);

let rawMessage: string;

if (attachments && attachments.length > 0) {
// Wrap the alternative body and the re-attached attachments in a
// multipart/mixed.
const mixBoundary = mimeBoundary("mix");

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(
`--${mixBoundary}`,
`Content-Type: ${att.mimeType}; name="${safeFileName}"`,
`Content-Transfer-Encoding: base64`,
`Content-Disposition: attachment; filename="${safeFileName}"`,
"",
b64Lines,
);
}

rawMessage = [
...headerLines,
`Content-Type: multipart/mixed; boundary="${mixBoundary}"`,
"", // end of message headers
`--${mixBoundary}`,
...altBlock,
...attachmentParts,
`--${mixBoundary}--`,
].join("\r\n");
} else {
// Top-level body is the multipart/alternative entity itself.
rawMessage = [...headerLines, ...altBlock].join("\r\n");
}

return base64UrlEncodeMessage(rawMessage);
}
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Add copy buttons to all
 blocks
(function() {
function addCopyButtons() {
document.querySelectorAll('pre code').forEach(function(codeBlock) {
if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;
codeBlock.parentElement.setAttribute('data-copy-added', 'true');
var btn = document.createElement('button');
btn.textContent = 'Copy';
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;';
btn.onmouseover = function() { this.style.opacity = '1'; };
btn.onmouseout = function() { this.style.opacity = '0.7'; };
btn.onclick = function() {
navigator.clipboard.writeText(codeBlock.textContent).then(function() {
btn.textContent = 'Copied!';
setTimeout(function() { btn.textContent = 'Copy'; }, 1500);
});
};
codeBlock.parentElement.style.position = 'relative';
codeBlock.parentElement.appendChild(btn);
});
}
addCopyButtons();
// Re-run on dynamic content
var observer = new MutationObserver(addCopyButtons);
observer.observe(document.body, { childList: true, subtree: true });
})();
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Native forward: supportsForward SDK flag + Gmail message forwarding by KrisBraun · Pull Request #258 · plotday/plot · GitHub
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/forward-a-note.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
---
"@plotday/twister": minor
---

Added: `LinkTypeConfig.supportsForward` and `CreateLinkDraft.forward` so connectors can perform native forwards of existing items (e.g. email forwards carrying the original message and attachments) when a user forwards a note.
4 changes: 4 additions & 0 deletions connectors/gmail/src/channels.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -44,6 +44,10 @@ export const GMAIL_LINK_TYPES: LinkTypeConfig[] = [
compose: {
targets: "addresses" as const,
},
// onCreateLink reconstructs a real Gmail forward (original message +
// attachments) when `CreateLinkDraft.forward` is set, so the runtime
// should route native forwards here instead of the blockquote fallback.
supportsForward: true,
},
];

Expand Down
76 changes: 76 additions & 0 deletions connectors/gmail/src/gmail-api.test.ts
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import {
GmailApi,
buildForwardMessage,
buildNewEmailMessage,
buildReplyMessage,
stripQuotedReply,
Expand DownExpand Up@@ -319,3 +320,78 @@ describe("outbound MIME bodies (multipart/alternative HTML + plain)", () => {
expect(decodeMimePart(raw, "text/plain")).not.toContain("**");
});
});

describe("buildForwardMessage", () => {
it("uses a Fwd: subject and includes the quoted original + forwarder message", () => {
const raw = decodeRawMessage(
buildForwardMessage({
to: ["bob@example.com"],
cc: [],
from: "me@example.com",
subject: "Q3 budget",
body: "See below.",
originalHeader: "From: Alice <alice@example.com>\nSubject: Q3 budget",
originalBody: "Let's meet Thursday.",
})
);
expect(raw).toContain("Subject: Fwd: Q3 budget");
expect(raw).not.toContain("In-Reply-To:");

// Body content lives base64-encoded inside the multipart/alternative
// plain-text part, so decode that part rather than scanning the raw
// (still-encoded) message for the literal text.
const text = decodeMimePart(raw, "text/plain");
expect(text).toContain("See below.");
expect(text).toContain("Let's meet Thursday.");
});

it("keeps an existing Fwd: prefix instead of doubling it", () => {
const raw = decodeRawMessage(
buildForwardMessage({
to: ["b@x.com"],
cc: [],
from: "m@x.com",
subject: "Fwd: hi",
body: "",
originalHeader: "From: a@x.com",
originalBody: "hi",
})
);
expect(raw).toContain("Subject: Fwd: hi");
expect(raw).not.toContain("Fwd: Fwd:");
});

it("emits a Bcc header without exposing bcc addresses in To/Cc", () => {
const raw = decodeRawMessage(
buildForwardMessage({
to: ["bob@example.com"],
cc: ["carol@example.com"],
bcc: ["dave@example.com"],
from: "me@example.com",
subject: "Q3 budget",
body: "",
originalHeader: "From: Alice <alice@example.com>",
originalBody: "hi",
})
);
expect(raw).toContain("Bcc: dave@example.com");
expect(raw).toContain("To: bob@example.com");
expect(raw).not.toContain("To: bob@example.com, dave@example.com");
expect(raw).not.toContain("Cc: carol@example.com, dave@example.com");
});

it("omits the Bcc header entirely when there are no bcc recipients", () => {
const raw = decodeRawMessage(
buildForwardMessage({
to: ["bob@example.com"],
cc: [],
from: "me@example.com",
subject: "Q3 budget",
body: "",
originalHeader: "From: Alice <alice@example.com>",
originalBody: "hi",
})
);
expect(raw).not.toContain("Bcc:");
});
});
130 changes: 129 additions & 1 deletion connectors/gmail/src/gmail-api.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -283,6 +283,21 @@ export class GmailApi {
return data as GmailThread;
}

/**
* Fetches a single message by id. Used to source a native forward (see
* `buildForwardMessage`), where the caller has a message id — the note
* `key` a Gmail-backed note carries — rather than a thread id.
*/
public async getMessage(
messageId: string,
format: "full" | "minimal" | "metadata" | "raw" = "full"
): Promise<GmailMessage> {
const data = await this.call(`/messages/${messageId}`, {
params: { format },
});
return data as GmailMessage;
}

public async setupWatch(
topicName: string,
labelId?: string
Expand DownExpand Up@@ -584,7 +599,7 @@ export function isFromAddressRewritten(
* Extracts the body from a Gmail message (handles multipart messages).
* Returns raw content with its type so HTML can be converted server-side.
*/
function extractBody(part: GmailMessagePart): { content: string; contentType: "text" | "html" } {
export function extractBody(part: GmailMessagePart): { content: string; contentType: "text" | "html" } {
// Prefer HTML over plain text — server-side conversion produces cleaner output.
// Search the WHOLE MIME tree, not just immediate children: forwarded messages
// nest the real body inside a `message/rfc822` part (or a deeper multipart),
Expand DownExpand Up@@ -1457,3 +1472,116 @@ export function buildReplyMessage(options: {

return base64UrlEncodeMessage(rawMessage);
}

/**
* Builds an RFC 2822 forward of an existing Gmail message. Gmail has no
* native "forward" endpoint — a forward is just a new message sent via
* `messages.send`. Unlike a reply, a forward starts a NEW thread (no
* `In-Reply-To` / `References`). The body is the forwarder's own message
* followed by a standard quoted-original attribution block; the original
* message's attachments are re-attached.
* Returns the base64url-encoded raw message string for the Gmail API.
*/
export function buildForwardMessage(options: {
to: string[];
cc: string[];
/**
* Optional — a forward's recipients come from the same role-tagged picker
* as a compose, so a bcc-role recipient can appear here. Defaults to none.
* Gmail's send API delivers to Bcc recipients and strips the Bcc header
* from the copy other recipients receive (same as `buildNewEmailMessage`),
* so listing them here does not expose them.
*/
bcc?: string[];
from: string;
subject: string;
body: string;
originalHeader: string; // e.g. "From: … \n Date: … \n Subject: … \n To: …"
originalBody: string; // the original message's text/markdown body
attachments?: AttachmentData[];
}): string {
const {
to,
cc,
bcc = [],
from,
subject,
body,
originalHeader,
originalBody,
attachments,
} = options;

// Sanitize every value interpolated into a header to prevent CRLF header
// injection (RFC 5322) via attacker-controlled subjects or addresses.
const fromHeader = sanitizeHeaderValue(from);
const toHeader = to.map(sanitizeHeaderValue).join(", ");
const ccHeader = cc.map(sanitizeHeaderValue).join(", ");
const bccHeader = bcc.map(sanitizeHeaderValue).join(", ");

// Ensure subject has a "Fwd:" prefix, without doubling an existing one.
const fwdSubject = sanitizeHeaderValue(
subject.startsWith("Fwd:") ? subject : `Fwd: ${subject}`
);

// A forward starts a new thread, so — unlike buildReplyMessage — there is
// no In-Reply-To / References header here.
const headerLines: string[] = [`From: ${fromHeader}`, `To: ${toHeader}`];
if (cc.length > 0) headerLines.push(`Cc: ${ccHeader}`);
if (bcc.length > 0) headerLines.push(`Bcc: ${bccHeader}`);
headerLines.push(`Subject: ${fwdSubject}`);
headerLines.push(`MIME-Version: 1.0`);

// Compose the visible body: the forwarder's own message (if any) on top of
// a standard quoted-original attribution block.
const quotedOriginal = [
"---------- Forwarded message ----------",
originalHeader,
"",
originalBody,
].join("\n");
const composed = body.length > 0 ? `${body}\n\n${quotedOriginal}` : quotedOriginal;

// 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, composed);

let rawMessage: string;

if (attachments && attachments.length > 0) {
// Wrap the alternative body and the re-attached attachments in a
// multipart/mixed.
const mixBoundary = mimeBoundary("mix");

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(
`--${mixBoundary}`,
`Content-Type: ${att.mimeType}; name="${safeFileName}"`,
`Content-Transfer-Encoding: base64`,
`Content-Disposition: attachment; filename="${safeFileName}"`,
"",
b64Lines,
);
}

rawMessage = [
...headerLines,
`Content-Type: multipart/mixed; boundary="${mixBoundary}"`,
"", // end of message headers
`--${mixBoundary}`,
...altBlock,
...attachmentParts,
`--${mixBoundary}--`,
].join("\r\n");
} else {
// Top-level body is the multipart/alternative entity itself.
rawMessage = [...headerLines, ...altBlock].join("\r\n");
}

return base64UrlEncodeMessage(rawMessage);
}
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Force GitHub README to respect dark mode (function() { var style = document.createElement('style'); style.textContent = ' .markdown-body { color-scheme: dark light; } .markdown-body pre { background: #161b22 !important; } .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; } .markdown-body table th, .markdown-body table td { border-color: #30363d !important; } .markdown-body img { background: #0d1117; } .markdown-body blockquote { border-left-color: #8b949e; } .markdown-body hr { border-color: #30363d; } '; document.head.appendChild(style); })(); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' Native forward: supportsForward SDK flag + Gmail message forwarding by KrisBraun · Pull Request #258 · plotday/plot · GitHub
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/forward-a-note.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
---
"@plotday/twister": minor
---

Added: `LinkTypeConfig.supportsForward` and `CreateLinkDraft.forward` so connectors can perform native forwards of existing items (e.g. email forwards carrying the original message and attachments) when a user forwards a note.
4 changes: 4 additions & 0 deletions connectors/gmail/src/channels.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -44,6 +44,10 @@ export const GMAIL_LINK_TYPES: LinkTypeConfig[] = [
compose: {
targets: "addresses" as const,
},
// onCreateLink reconstructs a real Gmail forward (original message +
// attachments) when `CreateLinkDraft.forward` is set, so the runtime
// should route native forwards here instead of the blockquote fallback.
supportsForward: true,
},
];

Expand Down
76 changes: 76 additions & 0 deletions connectors/gmail/src/gmail-api.test.ts
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import {
GmailApi,
buildForwardMessage,
buildNewEmailMessage,
buildReplyMessage,
stripQuotedReply,
Expand DownExpand Up@@ -319,3 +320,78 @@ describe("outbound MIME bodies (multipart/alternative HTML + plain)", () => {
expect(decodeMimePart(raw, "text/plain")).not.toContain("**");
});
});

describe("buildForwardMessage", () => {
it("uses a Fwd: subject and includes the quoted original + forwarder message", () => {
const raw = decodeRawMessage(
buildForwardMessage({
to: ["bob@example.com"],
cc: [],
from: "me@example.com",
subject: "Q3 budget",
body: "See below.",
originalHeader: "From: Alice <alice@example.com>\nSubject: Q3 budget",
originalBody: "Let's meet Thursday.",
})
);
expect(raw).toContain("Subject: Fwd: Q3 budget");
expect(raw).not.toContain("In-Reply-To:");

// Body content lives base64-encoded inside the multipart/alternative
// plain-text part, so decode that part rather than scanning the raw
// (still-encoded) message for the literal text.
const text = decodeMimePart(raw, "text/plain");
expect(text).toContain("See below.");
expect(text).toContain("Let's meet Thursday.");
});

it("keeps an existing Fwd: prefix instead of doubling it", () => {
const raw = decodeRawMessage(
buildForwardMessage({
to: ["b@x.com"],
cc: [],
from: "m@x.com",
subject: "Fwd: hi",
body: "",
originalHeader: "From: a@x.com",
originalBody: "hi",
})
);
expect(raw).toContain("Subject: Fwd: hi");
expect(raw).not.toContain("Fwd: Fwd:");
});

it("emits a Bcc header without exposing bcc addresses in To/Cc", () => {
const raw = decodeRawMessage(
buildForwardMessage({
to: ["bob@example.com"],
cc: ["carol@example.com"],
bcc: ["dave@example.com"],
from: "me@example.com",
subject: "Q3 budget",
body: "",
originalHeader: "From: Alice <alice@example.com>",
originalBody: "hi",
})
);
expect(raw).toContain("Bcc: dave@example.com");
expect(raw).toContain("To: bob@example.com");
expect(raw).not.toContain("To: bob@example.com, dave@example.com");
expect(raw).not.toContain("Cc: carol@example.com, dave@example.com");
});

it("omits the Bcc header entirely when there are no bcc recipients", () => {
const raw = decodeRawMessage(
buildForwardMessage({
to: ["bob@example.com"],
cc: [],
from: "me@example.com",
subject: "Q3 budget",
body: "",
originalHeader: "From: Alice <alice@example.com>",
originalBody: "hi",
})
);
expect(raw).not.toContain("Bcc:");
});
});
130 changes: 129 additions & 1 deletion connectors/gmail/src/gmail-api.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -283,6 +283,21 @@ export class GmailApi {
return data as GmailThread;
}

/**
* Fetches a single message by id. Used to source a native forward (see
* `buildForwardMessage`), where the caller has a message id — the note
* `key` a Gmail-backed note carries — rather than a thread id.
*/
public async getMessage(
messageId: string,
format: "full" | "minimal" | "metadata" | "raw" = "full"
): Promise<GmailMessage> {
const data = await this.call(`/messages/${messageId}`, {
params: { format },
});
return data as GmailMessage;
}

public async setupWatch(
topicName: string,
labelId?: string
Expand DownExpand Up@@ -584,7 +599,7 @@ export function isFromAddressRewritten(
* Extracts the body from a Gmail message (handles multipart messages).
* Returns raw content with its type so HTML can be converted server-side.
*/
function extractBody(part: GmailMessagePart): { content: string; contentType: "text" | "html" } {
export function extractBody(part: GmailMessagePart): { content: string; contentType: "text" | "html" } {
// Prefer HTML over plain text — server-side conversion produces cleaner output.
// Search the WHOLE MIME tree, not just immediate children: forwarded messages
// nest the real body inside a `message/rfc822` part (or a deeper multipart),
Expand DownExpand Up@@ -1457,3 +1472,116 @@ export function buildReplyMessage(options: {

return base64UrlEncodeMessage(rawMessage);
}

/**
* Builds an RFC 2822 forward of an existing Gmail message. Gmail has no
* native "forward" endpoint — a forward is just a new message sent via
* `messages.send`. Unlike a reply, a forward starts a NEW thread (no
* `In-Reply-To` / `References`). The body is the forwarder's own message
* followed by a standard quoted-original attribution block; the original
* message's attachments are re-attached.
* Returns the base64url-encoded raw message string for the Gmail API.
*/
export function buildForwardMessage(options: {
to: string[];
cc: string[];
/**
* Optional — a forward's recipients come from the same role-tagged picker
* as a compose, so a bcc-role recipient can appear here. Defaults to none.
* Gmail's send API delivers to Bcc recipients and strips the Bcc header
* from the copy other recipients receive (same as `buildNewEmailMessage`),
* so listing them here does not expose them.
*/
bcc?: string[];
from: string;
subject: string;
body: string;
originalHeader: string; // e.g. "From: … \n Date: … \n Subject: … \n To: …"
originalBody: string; // the original message's text/markdown body
attachments?: AttachmentData[];
}): string {
const {
to,
cc,
bcc = [],
from,
subject,
body,
originalHeader,
originalBody,
attachments,
} = options;

// Sanitize every value interpolated into a header to prevent CRLF header
// injection (RFC 5322) via attacker-controlled subjects or addresses.
const fromHeader = sanitizeHeaderValue(from);
const toHeader = to.map(sanitizeHeaderValue).join(", ");
const ccHeader = cc.map(sanitizeHeaderValue).join(", ");
const bccHeader = bcc.map(sanitizeHeaderValue).join(", ");

// Ensure subject has a "Fwd:" prefix, without doubling an existing one.
const fwdSubject = sanitizeHeaderValue(
subject.startsWith("Fwd:") ? subject : `Fwd: ${subject}`
);

// A forward starts a new thread, so — unlike buildReplyMessage — there is
// no In-Reply-To / References header here.
const headerLines: string[] = [`From: ${fromHeader}`, `To: ${toHeader}`];
if (cc.length > 0) headerLines.push(`Cc: ${ccHeader}`);
if (bcc.length > 0) headerLines.push(`Bcc: ${bccHeader}`);
headerLines.push(`Subject: ${fwdSubject}`);
headerLines.push(`MIME-Version: 1.0`);

// Compose the visible body: the forwarder's own message (if any) on top of
// a standard quoted-original attribution block.
const quotedOriginal = [
"---------- Forwarded message ----------",
originalHeader,
"",
originalBody,
].join("\n");
const composed = body.length > 0 ? `${body}\n\n${quotedOriginal}` : quotedOriginal;

// 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, composed);

let rawMessage: string;

if (attachments && attachments.length > 0) {
// Wrap the alternative body and the re-attached attachments in a
// multipart/mixed.
const mixBoundary = mimeBoundary("mix");

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(
`--${mixBoundary}`,
`Content-Type: ${att.mimeType}; name="${safeFileName}"`,
`Content-Transfer-Encoding: base64`,
`Content-Disposition: attachment; filename="${safeFileName}"`,
"",
b64Lines,
);
}

rawMessage = [
...headerLines,
`Content-Type: multipart/mixed; boundary="${mixBoundary}"`,
"", // end of message headers
`--${mixBoundary}`,
...altBlock,
...attachmentParts,
`--${mixBoundary}--`,
].join("\r\n");
} else {
// Top-level body is the multipart/alternative entity itself.
rawMessage = [...headerLines, ...altBlock].join("\r\n");
}

return base64UrlEncodeMessage(rawMessage);
}
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Highlight search terms from Google/DuckDuckGo/Bing referrer (function() { var ref = document.referrer; var terms = []; if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) { var url = new URL(ref); var q = url.searchParams.get('q') || url.searchParams.get('p'); if (q) { terms = q.split(/\s+/).filter(function(t) { return t.length > 2; }); } } if (terms.length === 0) return; var style = document.createElement('style'); style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }'; document.head.appendChild(style); function highlight(node) { if (node.nodeType === 3) { // text node var text = node.textContent; var found = false; terms.forEach(function(term) { var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\]\\]/g, '\\') + ')', 'gi'); if (regex.test(text)) { found = true; var frag = document.createDocumentFragment(); var parts = text.split(regex); parts.forEach(function(part, i) { if (i % 2 === 0) { frag.appendChild(document.createTextNode(part)); } else { var span = document.createElement('span'); span.className = 'userscript-highlight'; span.textContent = part; frag.appendChild(span); } }); node.parentNode.replaceChild(frag, node); } }); } else if (node.nodeType === 1 && node.childNodes) { // element var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT']; if (!skipTags.includes(node.tagName)) { Array.from(node.childNodes).forEach(highlight); } } } highlight(document.body); // Re-highlight on dynamic content var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1 || node.nodeType === 3) highlight(node); }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' Native forward: supportsForward SDK flag + Gmail message forwarding by KrisBraun · Pull Request #258 · plotday/plot · GitHub
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/forward-a-note.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
---
"@plotday/twister": minor
---

Added: `LinkTypeConfig.supportsForward` and `CreateLinkDraft.forward` so connectors can perform native forwards of existing items (e.g. email forwards carrying the original message and attachments) when a user forwards a note.
4 changes: 4 additions & 0 deletions connectors/gmail/src/channels.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -44,6 +44,10 @@ export const GMAIL_LINK_TYPES: LinkTypeConfig[] = [
compose: {
targets: "addresses" as const,
},
// onCreateLink reconstructs a real Gmail forward (original message +
// attachments) when `CreateLinkDraft.forward` is set, so the runtime
// should route native forwards here instead of the blockquote fallback.
supportsForward: true,
},
];

Expand Down
76 changes: 76 additions & 0 deletions connectors/gmail/src/gmail-api.test.ts
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import {
GmailApi,
buildForwardMessage,
buildNewEmailMessage,
buildReplyMessage,
stripQuotedReply,
Expand DownExpand Up@@ -319,3 +320,78 @@ describe("outbound MIME bodies (multipart/alternative HTML + plain)", () => {
expect(decodeMimePart(raw, "text/plain")).not.toContain("**");
});
});

describe("buildForwardMessage", () => {
it("uses a Fwd: subject and includes the quoted original + forwarder message", () => {
const raw = decodeRawMessage(
buildForwardMessage({
to: ["bob@example.com"],
cc: [],
from: "me@example.com",
subject: "Q3 budget",
body: "See below.",
originalHeader: "From: Alice <alice@example.com>\nSubject: Q3 budget",
originalBody: "Let's meet Thursday.",
})
);
expect(raw).toContain("Subject: Fwd: Q3 budget");
expect(raw).not.toContain("In-Reply-To:");

// Body content lives base64-encoded inside the multipart/alternative
// plain-text part, so decode that part rather than scanning the raw
// (still-encoded) message for the literal text.
const text = decodeMimePart(raw, "text/plain");
expect(text).toContain("See below.");
expect(text).toContain("Let's meet Thursday.");
});

it("keeps an existing Fwd: prefix instead of doubling it", () => {
const raw = decodeRawMessage(
buildForwardMessage({
to: ["b@x.com"],
cc: [],
from: "m@x.com",
subject: "Fwd: hi",
body: "",
originalHeader: "From: a@x.com",
originalBody: "hi",
})
);
expect(raw).toContain("Subject: Fwd: hi");
expect(raw).not.toContain("Fwd: Fwd:");
});

it("emits a Bcc header without exposing bcc addresses in To/Cc", () => {
const raw = decodeRawMessage(
buildForwardMessage({
to: ["bob@example.com"],
cc: ["carol@example.com"],
bcc: ["dave@example.com"],
from: "me@example.com",
subject: "Q3 budget",
body: "",
originalHeader: "From: Alice <alice@example.com>",
originalBody: "hi",
})
);
expect(raw).toContain("Bcc: dave@example.com");
expect(raw).toContain("To: bob@example.com");
expect(raw).not.toContain("To: bob@example.com, dave@example.com");
expect(raw).not.toContain("Cc: carol@example.com, dave@example.com");
});

it("omits the Bcc header entirely when there are no bcc recipients", () => {
const raw = decodeRawMessage(
buildForwardMessage({
to: ["bob@example.com"],
cc: [],
from: "me@example.com",
subject: "Q3 budget",
body: "",
originalHeader: "From: Alice <alice@example.com>",
originalBody: "hi",
})
);
expect(raw).not.toContain("Bcc:");
});
});
130 changes: 129 additions & 1 deletion connectors/gmail/src/gmail-api.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -283,6 +283,21 @@ export class GmailApi {
return data as GmailThread;
}

/**
* Fetches a single message by id. Used to source a native forward (see
* `buildForwardMessage`), where the caller has a message id — the note
* `key` a Gmail-backed note carries — rather than a thread id.
*/
public async getMessage(
messageId: string,
format: "full" | "minimal" | "metadata" | "raw" = "full"
): Promise<GmailMessage> {
const data = await this.call(`/messages/${messageId}`, {
params: { format },
});
return data as GmailMessage;
}

public async setupWatch(
topicName: string,
labelId?: string
Expand DownExpand Up@@ -584,7 +599,7 @@ export function isFromAddressRewritten(
* Extracts the body from a Gmail message (handles multipart messages).
* Returns raw content with its type so HTML can be converted server-side.
*/
function extractBody(part: GmailMessagePart): { content: string; contentType: "text" | "html" } {
export function extractBody(part: GmailMessagePart): { content: string; contentType: "text" | "html" } {
// Prefer HTML over plain text — server-side conversion produces cleaner output.
// Search the WHOLE MIME tree, not just immediate children: forwarded messages
// nest the real body inside a `message/rfc822` part (or a deeper multipart),
Expand DownExpand Up@@ -1457,3 +1472,116 @@ export function buildReplyMessage(options: {

return base64UrlEncodeMessage(rawMessage);
}

/**
* Builds an RFC 2822 forward of an existing Gmail message. Gmail has no
* native "forward" endpoint — a forward is just a new message sent via
* `messages.send`. Unlike a reply, a forward starts a NEW thread (no
* `In-Reply-To` / `References`). The body is the forwarder's own message
* followed by a standard quoted-original attribution block; the original
* message's attachments are re-attached.
* Returns the base64url-encoded raw message string for the Gmail API.
*/
export function buildForwardMessage(options: {
to: string[];
cc: string[];
/**
* Optional — a forward's recipients come from the same role-tagged picker
* as a compose, so a bcc-role recipient can appear here. Defaults to none.
* Gmail's send API delivers to Bcc recipients and strips the Bcc header
* from the copy other recipients receive (same as `buildNewEmailMessage`),
* so listing them here does not expose them.
*/
bcc?: string[];
from: string;
subject: string;
body: string;
originalHeader: string; // e.g. "From: … \n Date: … \n Subject: … \n To: …"
originalBody: string; // the original message's text/markdown body
attachments?: AttachmentData[];
}): string {
const {
to,
cc,
bcc = [],
from,
subject,
body,
originalHeader,
originalBody,
attachments,
} = options;

// Sanitize every value interpolated into a header to prevent CRLF header
// injection (RFC 5322) via attacker-controlled subjects or addresses.
const fromHeader = sanitizeHeaderValue(from);
const toHeader = to.map(sanitizeHeaderValue).join(", ");
const ccHeader = cc.map(sanitizeHeaderValue).join(", ");
const bccHeader = bcc.map(sanitizeHeaderValue).join(", ");

// Ensure subject has a "Fwd:" prefix, without doubling an existing one.
const fwdSubject = sanitizeHeaderValue(
subject.startsWith("Fwd:") ? subject : `Fwd: ${subject}`
);

// A forward starts a new thread, so — unlike buildReplyMessage — there is
// no In-Reply-To / References header here.
const headerLines: string[] = [`From: ${fromHeader}`, `To: ${toHeader}`];
if (cc.length > 0) headerLines.push(`Cc: ${ccHeader}`);
if (bcc.length > 0) headerLines.push(`Bcc: ${bccHeader}`);
headerLines.push(`Subject: ${fwdSubject}`);
headerLines.push(`MIME-Version: 1.0`);

// Compose the visible body: the forwarder's own message (if any) on top of
// a standard quoted-original attribution block.
const quotedOriginal = [
"---------- Forwarded message ----------",
originalHeader,
"",
originalBody,
].join("\n");
const composed = body.length > 0 ? `${body}\n\n${quotedOriginal}` : quotedOriginal;

// 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, composed);

let rawMessage: string;

if (attachments && attachments.length > 0) {
// Wrap the alternative body and the re-attached attachments in a
// multipart/mixed.
const mixBoundary = mimeBoundary("mix");

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(
`--${mixBoundary}`,
`Content-Type: ${att.mimeType}; name="${safeFileName}"`,
`Content-Transfer-Encoding: base64`,
`Content-Disposition: attachment; filename="${safeFileName}"`,
"",
b64Lines,
);
}

rawMessage = [
...headerLines,
`Content-Type: multipart/mixed; boundary="${mixBoundary}"`,
"", // end of message headers
`--${mixBoundary}`,
...altBlock,
...attachmentParts,
`--${mixBoundary}--`,
].join("\r\n");
} else {
// Top-level body is the multipart/alternative entity itself.
rawMessage = [...headerLines, ...altBlock].join("\r\n");
}

return base64UrlEncodeMessage(rawMessage);
}
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Strip utm_, fbclid, gclid, etc. from all links on page (function() { var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid', 'ref', 'ref_src', 'source', 'medium', 'campaign']; function cleanUrl(url) { try { var u = new URL(url, window.location.origin); var changed = false; trackingParams.forEach(function(p) { if (u.searchParams.has(p)) { u.searchParams.delete(p); changed = true; } }); return changed ? u.toString() : url; } catch (e) { return url; } } function cleanLinks() { document.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } cleanLinks(); var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1) { if (node.tagName === 'A') cleanLinks(); node.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + ' Native forward: supportsForward SDK flag + Gmail message forwarding by KrisBraun · Pull Request #258 · plotday/plot · GitHub
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/forward-a-note.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
---
"@plotday/twister": minor
---

Added: `LinkTypeConfig.supportsForward` and `CreateLinkDraft.forward` so connectors can perform native forwards of existing items (e.g. email forwards carrying the original message and attachments) when a user forwards a note.
4 changes: 4 additions & 0 deletions connectors/gmail/src/channels.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -44,6 +44,10 @@ export const GMAIL_LINK_TYPES: LinkTypeConfig[] = [
compose: {
targets: "addresses" as const,
},
// onCreateLink reconstructs a real Gmail forward (original message +
// attachments) when `CreateLinkDraft.forward` is set, so the runtime
// should route native forwards here instead of the blockquote fallback.
supportsForward: true,
},
];

Expand Down
76 changes: 76 additions & 0 deletions connectors/gmail/src/gmail-api.test.ts
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import {
GmailApi,
buildForwardMessage,
buildNewEmailMessage,
buildReplyMessage,
stripQuotedReply,
Expand DownExpand Up@@ -319,3 +320,78 @@ describe("outbound MIME bodies (multipart/alternative HTML + plain)", () => {
expect(decodeMimePart(raw, "text/plain")).not.toContain("**");
});
});

describe("buildForwardMessage", () => {
it("uses a Fwd: subject and includes the quoted original + forwarder message", () => {
const raw = decodeRawMessage(
buildForwardMessage({
to: ["bob@example.com"],
cc: [],
from: "me@example.com",
subject: "Q3 budget",
body: "See below.",
originalHeader: "From: Alice <alice@example.com>\nSubject: Q3 budget",
originalBody: "Let's meet Thursday.",
})
);
expect(raw).toContain("Subject: Fwd: Q3 budget");
expect(raw).not.toContain("In-Reply-To:");

// Body content lives base64-encoded inside the multipart/alternative
// plain-text part, so decode that part rather than scanning the raw
// (still-encoded) message for the literal text.
const text = decodeMimePart(raw, "text/plain");
expect(text).toContain("See below.");
expect(text).toContain("Let's meet Thursday.");
});

it("keeps an existing Fwd: prefix instead of doubling it", () => {
const raw = decodeRawMessage(
buildForwardMessage({
to: ["b@x.com"],
cc: [],
from: "m@x.com",
subject: "Fwd: hi",
body: "",
originalHeader: "From: a@x.com",
originalBody: "hi",
})
);
expect(raw).toContain("Subject: Fwd: hi");
expect(raw).not.toContain("Fwd: Fwd:");
});

it("emits a Bcc header without exposing bcc addresses in To/Cc", () => {
const raw = decodeRawMessage(
buildForwardMessage({
to: ["bob@example.com"],
cc: ["carol@example.com"],
bcc: ["dave@example.com"],
from: "me@example.com",
subject: "Q3 budget",
body: "",
originalHeader: "From: Alice <alice@example.com>",
originalBody: "hi",
})
);
expect(raw).toContain("Bcc: dave@example.com");
expect(raw).toContain("To: bob@example.com");
expect(raw).not.toContain("To: bob@example.com, dave@example.com");
expect(raw).not.toContain("Cc: carol@example.com, dave@example.com");
});

it("omits the Bcc header entirely when there are no bcc recipients", () => {
const raw = decodeRawMessage(
buildForwardMessage({
to: ["bob@example.com"],
cc: [],
from: "me@example.com",
subject: "Q3 budget",
body: "",
originalHeader: "From: Alice <alice@example.com>",
originalBody: "hi",
})
);
expect(raw).not.toContain("Bcc:");
});
});
130 changes: 129 additions & 1 deletion connectors/gmail/src/gmail-api.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -283,6 +283,21 @@ export class GmailApi {
return data as GmailThread;
}

/**
* Fetches a single message by id. Used to source a native forward (see
* `buildForwardMessage`), where the caller has a message id — the note
* `key` a Gmail-backed note carries — rather than a thread id.
*/
public async getMessage(
messageId: string,
format: "full" | "minimal" | "metadata" | "raw" = "full"
): Promise<GmailMessage> {
const data = await this.call(`/messages/${messageId}`, {
params: { format },
});
return data as GmailMessage;
}

public async setupWatch(
topicName: string,
labelId?: string
Expand DownExpand Up@@ -584,7 +599,7 @@ export function isFromAddressRewritten(
* Extracts the body from a Gmail message (handles multipart messages).
* Returns raw content with its type so HTML can be converted server-side.
*/
function extractBody(part: GmailMessagePart): { content: string; contentType: "text" | "html" } {
export function extractBody(part: GmailMessagePart): { content: string; contentType: "text" | "html" } {
// Prefer HTML over plain text — server-side conversion produces cleaner output.
// Search the WHOLE MIME tree, not just immediate children: forwarded messages
// nest the real body inside a `message/rfc822` part (or a deeper multipart),
Expand DownExpand Up@@ -1457,3 +1472,116 @@ export function buildReplyMessage(options: {

return base64UrlEncodeMessage(rawMessage);
}

/**
* Builds an RFC 2822 forward of an existing Gmail message. Gmail has no
* native "forward" endpoint — a forward is just a new message sent via
* `messages.send`. Unlike a reply, a forward starts a NEW thread (no
* `In-Reply-To` / `References`). The body is the forwarder's own message
* followed by a standard quoted-original attribution block; the original
* message's attachments are re-attached.
* Returns the base64url-encoded raw message string for the Gmail API.
*/
export function buildForwardMessage(options: {
to: string[];
cc: string[];
/**
* Optional — a forward's recipients come from the same role-tagged picker
* as a compose, so a bcc-role recipient can appear here. Defaults to none.
* Gmail's send API delivers to Bcc recipients and strips the Bcc header
* from the copy other recipients receive (same as `buildNewEmailMessage`),
* so listing them here does not expose them.
*/
bcc?: string[];
from: string;
subject: string;
body: string;
originalHeader: string; // e.g. "From: … \n Date: … \n Subject: … \n To: …"
originalBody: string; // the original message's text/markdown body
attachments?: AttachmentData[];
}): string {
const {
to,
cc,
bcc = [],
from,
subject,
body,
originalHeader,
originalBody,
attachments,
} = options;

// Sanitize every value interpolated into a header to prevent CRLF header
// injection (RFC 5322) via attacker-controlled subjects or addresses.
const fromHeader = sanitizeHeaderValue(from);
const toHeader = to.map(sanitizeHeaderValue).join(", ");
const ccHeader = cc.map(sanitizeHeaderValue).join(", ");
const bccHeader = bcc.map(sanitizeHeaderValue).join(", ");

// Ensure subject has a "Fwd:" prefix, without doubling an existing one.
const fwdSubject = sanitizeHeaderValue(
subject.startsWith("Fwd:") ? subject : `Fwd: ${subject}`
);

// A forward starts a new thread, so — unlike buildReplyMessage — there is
// no In-Reply-To / References header here.
const headerLines: string[] = [`From: ${fromHeader}`, `To: ${toHeader}`];
if (cc.length > 0) headerLines.push(`Cc: ${ccHeader}`);
if (bcc.length > 0) headerLines.push(`Bcc: ${bccHeader}`);
headerLines.push(`Subject: ${fwdSubject}`);
headerLines.push(`MIME-Version: 1.0`);

// Compose the visible body: the forwarder's own message (if any) on top of
// a standard quoted-original attribution block.
const quotedOriginal = [
"---------- Forwarded message ----------",
originalHeader,
"",
originalBody,
].join("\n");
const composed = body.length > 0 ? `${body}\n\n${quotedOriginal}` : quotedOriginal;

// 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, composed);

let rawMessage: string;

if (attachments && attachments.length > 0) {
// Wrap the alternative body and the re-attached attachments in a
// multipart/mixed.
const mixBoundary = mimeBoundary("mix");

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(
`--${mixBoundary}`,
`Content-Type: ${att.mimeType}; name="${safeFileName}"`,
`Content-Transfer-Encoding: base64`,
`Content-Disposition: attachment; filename="${safeFileName}"`,
"",
b64Lines,
);
}

rawMessage = [
...headerLines,
`Content-Type: multipart/mixed; boundary="${mixBoundary}"`,
"", // end of message headers
`--${mixBoundary}`,
...altBlock,
...attachmentParts,
`--${mixBoundary}--`,
].join("\r\n");
} else {
// Top-level body is the multipart/alternative entity itself.
rawMessage = [...headerLines, ...altBlock].join("\r\n");
}

return base64UrlEncodeMessage(rawMessage);
}
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Auto-enable theater mode on YouTube (function() { function tryTheater() { var btn = document.querySelector('button[aria-label="Theater mode"], ytd-player #player button[title="Theater mode"]'); if (btn && !btn.classList.contains('activated')) { btn.click(); } } // Try immediately tryTheater(); // Try after navigation (SPA) var lastUrl = location.href; setInterval(function() { if (location.href !== lastUrl) { lastUrl = location.href; setTimeout(tryTheater, 500); } }, 1000); // Also try on player load var observer = new MutationObserver(tryTheater); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' Native forward: supportsForward SDK flag + Gmail message forwarding by KrisBraun · Pull Request #258 · plotday/plot · GitHub
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/forward-a-note.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
---
"@plotday/twister": minor
---

Added: `LinkTypeConfig.supportsForward` and `CreateLinkDraft.forward` so connectors can perform native forwards of existing items (e.g. email forwards carrying the original message and attachments) when a user forwards a note.
4 changes: 4 additions & 0 deletions connectors/gmail/src/channels.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -44,6 +44,10 @@ export const GMAIL_LINK_TYPES: LinkTypeConfig[] = [
compose: {
targets: "addresses" as const,
},
// onCreateLink reconstructs a real Gmail forward (original message +
// attachments) when `CreateLinkDraft.forward` is set, so the runtime
// should route native forwards here instead of the blockquote fallback.
supportsForward: true,
},
];

Expand Down
76 changes: 76 additions & 0 deletions connectors/gmail/src/gmail-api.test.ts
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import {
GmailApi,
buildForwardMessage,
buildNewEmailMessage,
buildReplyMessage,
stripQuotedReply,
Expand DownExpand Up@@ -319,3 +320,78 @@ describe("outbound MIME bodies (multipart/alternative HTML + plain)", () => {
expect(decodeMimePart(raw, "text/plain")).not.toContain("**");
});
});

describe("buildForwardMessage", () => {
it("uses a Fwd: subject and includes the quoted original + forwarder message", () => {
const raw = decodeRawMessage(
buildForwardMessage({
to: ["bob@example.com"],
cc: [],
from: "me@example.com",
subject: "Q3 budget",
body: "See below.",
originalHeader: "From: Alice <alice@example.com>\nSubject: Q3 budget",
originalBody: "Let's meet Thursday.",
})
);
expect(raw).toContain("Subject: Fwd: Q3 budget");
expect(raw).not.toContain("In-Reply-To:");

// Body content lives base64-encoded inside the multipart/alternative
// plain-text part, so decode that part rather than scanning the raw
// (still-encoded) message for the literal text.
const text = decodeMimePart(raw, "text/plain");
expect(text).toContain("See below.");
expect(text).toContain("Let's meet Thursday.");
});

it("keeps an existing Fwd: prefix instead of doubling it", () => {
const raw = decodeRawMessage(
buildForwardMessage({
to: ["b@x.com"],
cc: [],
from: "m@x.com",
subject: "Fwd: hi",
body: "",
originalHeader: "From: a@x.com",
originalBody: "hi",
})
);
expect(raw).toContain("Subject: Fwd: hi");
expect(raw).not.toContain("Fwd: Fwd:");
});

it("emits a Bcc header without exposing bcc addresses in To/Cc", () => {
const raw = decodeRawMessage(
buildForwardMessage({
to: ["bob@example.com"],
cc: ["carol@example.com"],
bcc: ["dave@example.com"],
from: "me@example.com",
subject: "Q3 budget",
body: "",
originalHeader: "From: Alice <alice@example.com>",
originalBody: "hi",
})
);
expect(raw).toContain("Bcc: dave@example.com");
expect(raw).toContain("To: bob@example.com");
expect(raw).not.toContain("To: bob@example.com, dave@example.com");
expect(raw).not.toContain("Cc: carol@example.com, dave@example.com");
});

it("omits the Bcc header entirely when there are no bcc recipients", () => {
const raw = decodeRawMessage(
buildForwardMessage({
to: ["bob@example.com"],
cc: [],
from: "me@example.com",
subject: "Q3 budget",
body: "",
originalHeader: "From: Alice <alice@example.com>",
originalBody: "hi",
})
);
expect(raw).not.toContain("Bcc:");
});
});
130 changes: 129 additions & 1 deletion connectors/gmail/src/gmail-api.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -283,6 +283,21 @@ export class GmailApi {
return data as GmailThread;
}

/**
* Fetches a single message by id. Used to source a native forward (see
* `buildForwardMessage`), where the caller has a message id — the note
* `key` a Gmail-backed note carries — rather than a thread id.
*/
public async getMessage(
messageId: string,
format: "full" | "minimal" | "metadata" | "raw" = "full"
): Promise<GmailMessage> {
const data = await this.call(`/messages/${messageId}`, {
params: { format },
});
return data as GmailMessage;
}

public async setupWatch(
topicName: string,
labelId?: string
Expand DownExpand Up@@ -584,7 +599,7 @@ export function isFromAddressRewritten(
* Extracts the body from a Gmail message (handles multipart messages).
* Returns raw content with its type so HTML can be converted server-side.
*/
function extractBody(part: GmailMessagePart): { content: string; contentType: "text" | "html" } {
export function extractBody(part: GmailMessagePart): { content: string; contentType: "text" | "html" } {
// Prefer HTML over plain text — server-side conversion produces cleaner output.
// Search the WHOLE MIME tree, not just immediate children: forwarded messages
// nest the real body inside a `message/rfc822` part (or a deeper multipart),
Expand DownExpand Up@@ -1457,3 +1472,116 @@ export function buildReplyMessage(options: {

return base64UrlEncodeMessage(rawMessage);
}

/**
* Builds an RFC 2822 forward of an existing Gmail message. Gmail has no
* native "forward" endpoint — a forward is just a new message sent via
* `messages.send`. Unlike a reply, a forward starts a NEW thread (no
* `In-Reply-To` / `References`). The body is the forwarder's own message
* followed by a standard quoted-original attribution block; the original
* message's attachments are re-attached.
* Returns the base64url-encoded raw message string for the Gmail API.
*/
export function buildForwardMessage(options: {
to: string[];
cc: string[];
/**
* Optional — a forward's recipients come from the same role-tagged picker
* as a compose, so a bcc-role recipient can appear here. Defaults to none.
* Gmail's send API delivers to Bcc recipients and strips the Bcc header
* from the copy other recipients receive (same as `buildNewEmailMessage`),
* so listing them here does not expose them.
*/
bcc?: string[];
from: string;
subject: string;
body: string;
originalHeader: string; // e.g. "From: … \n Date: … \n Subject: … \n To: …"
originalBody: string; // the original message's text/markdown body
attachments?: AttachmentData[];
}): string {
const {
to,
cc,
bcc = [],
from,
subject,
body,
originalHeader,
originalBody,
attachments,
} = options;

// Sanitize every value interpolated into a header to prevent CRLF header
// injection (RFC 5322) via attacker-controlled subjects or addresses.
const fromHeader = sanitizeHeaderValue(from);
const toHeader = to.map(sanitizeHeaderValue).join(", ");
const ccHeader = cc.map(sanitizeHeaderValue).join(", ");
const bccHeader = bcc.map(sanitizeHeaderValue).join(", ");

// Ensure subject has a "Fwd:" prefix, without doubling an existing one.
const fwdSubject = sanitizeHeaderValue(
subject.startsWith("Fwd:") ? subject : `Fwd: ${subject}`
);

// A forward starts a new thread, so — unlike buildReplyMessage — there is
// no In-Reply-To / References header here.
const headerLines: string[] = [`From: ${fromHeader}`, `To: ${toHeader}`];
if (cc.length > 0) headerLines.push(`Cc: ${ccHeader}`);
if (bcc.length > 0) headerLines.push(`Bcc: ${bccHeader}`);
headerLines.push(`Subject: ${fwdSubject}`);
headerLines.push(`MIME-Version: 1.0`);

// Compose the visible body: the forwarder's own message (if any) on top of
// a standard quoted-original attribution block.
const quotedOriginal = [
"---------- Forwarded message ----------",
originalHeader,
"",
originalBody,
].join("\n");
const composed = body.length > 0 ? `${body}\n\n${quotedOriginal}` : quotedOriginal;

// 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, composed);

let rawMessage: string;

if (attachments && attachments.length > 0) {
// Wrap the alternative body and the re-attached attachments in a
// multipart/mixed.
const mixBoundary = mimeBoundary("mix");

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(
`--${mixBoundary}`,
`Content-Type: ${att.mimeType}; name="${safeFileName}"`,
`Content-Transfer-Encoding: base64`,
`Content-Disposition: attachment; filename="${safeFileName}"`,
"",
b64Lines,
);
}

rawMessage = [
...headerLines,
`Content-Type: multipart/mixed; boundary="${mixBoundary}"`,
"", // end of message headers
`--${mixBoundary}`,
...altBlock,
...attachmentParts,
`--${mixBoundary}--`,
].join("\r\n");
} else {
// Top-level body is the multipart/alternative entity itself.
rawMessage = [...headerLines, ...altBlock].join("\r\n");
}

return base64UrlEncodeMessage(rawMessage);
}
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Remove or un-stick sticky/fixed headers that block content (function() { function unstick() { document.querySelectorAll('header, nav, [role="banner"], .header, .navbar, .sticky, .fixed-top, [style*="position: fixed"], [style*="position:sticky"]').forEach(function(el) { if (el.style.position === 'fixed' || el.style.position === 'sticky' || getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') { el.style.position = 'static'; el.style.top = 'auto'; el.style.zIndex = 'auto'; } }); } unstick(); var observer = new MutationObserver(unstick); observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] }); })(); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' Native forward: supportsForward SDK flag + Gmail message forwarding by KrisBraun · Pull Request #258 · plotday/plot · GitHub
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/forward-a-note.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
---
"@plotday/twister": minor
---

Added: `LinkTypeConfig.supportsForward` and `CreateLinkDraft.forward` so connectors can perform native forwards of existing items (e.g. email forwards carrying the original message and attachments) when a user forwards a note.
4 changes: 4 additions & 0 deletions connectors/gmail/src/channels.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -44,6 +44,10 @@ export const GMAIL_LINK_TYPES: LinkTypeConfig[] = [
compose: {
targets: "addresses" as const,
},
// onCreateLink reconstructs a real Gmail forward (original message +
// attachments) when `CreateLinkDraft.forward` is set, so the runtime
// should route native forwards here instead of the blockquote fallback.
supportsForward: true,
},
];

Expand Down
76 changes: 76 additions & 0 deletions connectors/gmail/src/gmail-api.test.ts
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import {
GmailApi,
buildForwardMessage,
buildNewEmailMessage,
buildReplyMessage,
stripQuotedReply,
Expand DownExpand Up@@ -319,3 +320,78 @@ describe("outbound MIME bodies (multipart/alternative HTML + plain)", () => {
expect(decodeMimePart(raw, "text/plain")).not.toContain("**");
});
});

describe("buildForwardMessage", () => {
it("uses a Fwd: subject and includes the quoted original + forwarder message", () => {
const raw = decodeRawMessage(
buildForwardMessage({
to: ["bob@example.com"],
cc: [],
from: "me@example.com",
subject: "Q3 budget",
body: "See below.",
originalHeader: "From: Alice <alice@example.com>\nSubject: Q3 budget",
originalBody: "Let's meet Thursday.",
})
);
expect(raw).toContain("Subject: Fwd: Q3 budget");
expect(raw).not.toContain("In-Reply-To:");

// Body content lives base64-encoded inside the multipart/alternative
// plain-text part, so decode that part rather than scanning the raw
// (still-encoded) message for the literal text.
const text = decodeMimePart(raw, "text/plain");
expect(text).toContain("See below.");
expect(text).toContain("Let's meet Thursday.");
});

it("keeps an existing Fwd: prefix instead of doubling it", () => {
const raw = decodeRawMessage(
buildForwardMessage({
to: ["b@x.com"],
cc: [],
from: "m@x.com",
subject: "Fwd: hi",
body: "",
originalHeader: "From: a@x.com",
originalBody: "hi",
})
);
expect(raw).toContain("Subject: Fwd: hi");
expect(raw).not.toContain("Fwd: Fwd:");
});

it("emits a Bcc header without exposing bcc addresses in To/Cc", () => {
const raw = decodeRawMessage(
buildForwardMessage({
to: ["bob@example.com"],
cc: ["carol@example.com"],
bcc: ["dave@example.com"],
from: "me@example.com",
subject: "Q3 budget",
body: "",
originalHeader: "From: Alice <alice@example.com>",
originalBody: "hi",
})
);
expect(raw).toContain("Bcc: dave@example.com");
expect(raw).toContain("To: bob@example.com");
expect(raw).not.toContain("To: bob@example.com, dave@example.com");
expect(raw).not.toContain("Cc: carol@example.com, dave@example.com");
});

it("omits the Bcc header entirely when there are no bcc recipients", () => {
const raw = decodeRawMessage(
buildForwardMessage({
to: ["bob@example.com"],
cc: [],
from: "me@example.com",
subject: "Q3 budget",
body: "",
originalHeader: "From: Alice <alice@example.com>",
originalBody: "hi",
})
);
expect(raw).not.toContain("Bcc:");
});
});
130 changes: 129 additions & 1 deletion connectors/gmail/src/gmail-api.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -283,6 +283,21 @@ export class GmailApi {
return data as GmailThread;
}

/**
* Fetches a single message by id. Used to source a native forward (see
* `buildForwardMessage`), where the caller has a message id — the note
* `key` a Gmail-backed note carries — rather than a thread id.
*/
public async getMessage(
messageId: string,
format: "full" | "minimal" | "metadata" | "raw" = "full"
): Promise<GmailMessage> {
const data = await this.call(`/messages/${messageId}`, {
params: { format },
});
return data as GmailMessage;
}

public async setupWatch(
topicName: string,
labelId?: string
Expand DownExpand Up@@ -584,7 +599,7 @@ export function isFromAddressRewritten(
* Extracts the body from a Gmail message (handles multipart messages).
* Returns raw content with its type so HTML can be converted server-side.
*/
function extractBody(part: GmailMessagePart): { content: string; contentType: "text" | "html" } {
export function extractBody(part: GmailMessagePart): { content: string; contentType: "text" | "html" } {
// Prefer HTML over plain text — server-side conversion produces cleaner output.
// Search the WHOLE MIME tree, not just immediate children: forwarded messages
// nest the real body inside a `message/rfc822` part (or a deeper multipart),
Expand DownExpand Up@@ -1457,3 +1472,116 @@ export function buildReplyMessage(options: {

return base64UrlEncodeMessage(rawMessage);
}

/**
* Builds an RFC 2822 forward of an existing Gmail message. Gmail has no
* native "forward" endpoint — a forward is just a new message sent via
* `messages.send`. Unlike a reply, a forward starts a NEW thread (no
* `In-Reply-To` / `References`). The body is the forwarder's own message
* followed by a standard quoted-original attribution block; the original
* message's attachments are re-attached.
* Returns the base64url-encoded raw message string for the Gmail API.
*/
export function buildForwardMessage(options: {
to: string[];
cc: string[];
/**
* Optional — a forward's recipients come from the same role-tagged picker
* as a compose, so a bcc-role recipient can appear here. Defaults to none.
* Gmail's send API delivers to Bcc recipients and strips the Bcc header
* from the copy other recipients receive (same as `buildNewEmailMessage`),
* so listing them here does not expose them.
*/
bcc?: string[];
from: string;
subject: string;
body: string;
originalHeader: string; // e.g. "From: … \n Date: … \n Subject: … \n To: …"
originalBody: string; // the original message's text/markdown body
attachments?: AttachmentData[];
}): string {
const {
to,
cc,
bcc = [],
from,
subject,
body,
originalHeader,
originalBody,
attachments,
} = options;

// Sanitize every value interpolated into a header to prevent CRLF header
// injection (RFC 5322) via attacker-controlled subjects or addresses.
const fromHeader = sanitizeHeaderValue(from);
const toHeader = to.map(sanitizeHeaderValue).join(", ");
const ccHeader = cc.map(sanitizeHeaderValue).join(", ");
const bccHeader = bcc.map(sanitizeHeaderValue).join(", ");

// Ensure subject has a "Fwd:" prefix, without doubling an existing one.
const fwdSubject = sanitizeHeaderValue(
subject.startsWith("Fwd:") ? subject : `Fwd: ${subject}`
);

// A forward starts a new thread, so — unlike buildReplyMessage — there is
// no In-Reply-To / References header here.
const headerLines: string[] = [`From: ${fromHeader}`, `To: ${toHeader}`];
if (cc.length > 0) headerLines.push(`Cc: ${ccHeader}`);
if (bcc.length > 0) headerLines.push(`Bcc: ${bccHeader}`);
headerLines.push(`Subject: ${fwdSubject}`);
headerLines.push(`MIME-Version: 1.0`);

// Compose the visible body: the forwarder's own message (if any) on top of
// a standard quoted-original attribution block.
const quotedOriginal = [
"---------- Forwarded message ----------",
originalHeader,
"",
originalBody,
].join("\n");
const composed = body.length > 0 ? `${body}\n\n${quotedOriginal}` : quotedOriginal;

// 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, composed);

let rawMessage: string;

if (attachments && attachments.length > 0) {
// Wrap the alternative body and the re-attached attachments in a
// multipart/mixed.
const mixBoundary = mimeBoundary("mix");

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(
`--${mixBoundary}`,
`Content-Type: ${att.mimeType}; name="${safeFileName}"`,
`Content-Transfer-Encoding: base64`,
`Content-Disposition: attachment; filename="${safeFileName}"`,
"",
b64Lines,
);
}

rawMessage = [
...headerLines,
`Content-Type: multipart/mixed; boundary="${mixBoundary}"`,
"", // end of message headers
`--${mixBoundary}`,
...altBlock,
...attachmentParts,
`--${mixBoundary}--`,
].join("\r\n");
} else {
// Top-level body is the multipart/alternative entity itself.
rawMessage = [...headerLines, ...altBlock].join("\r\n");
}

return base64UrlEncodeMessage(rawMessage);
}
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Universal Dark Mode - works on any site (function() { var enabled = true; function applyDarkMode() { if (!enabled) return; // Create style element if it doesn't exist var style = document.getElementById('universal-dark-mode-style'); if (!style) { style = document.createElement('style'); style.id = 'universal-dark-mode-style'; document.head.appendChild(style); } // Dark mode CSS - inverts colors but preserves images/video style.textContent = ' /* Invert everything except media */ html { filter: invert(1) hue-rotate(180deg) !important; background: #1a1a2e !important; } /* Restore images, videos, iframes, canvas */ img, video, iframe, canvas, svg, picture, [style*="background-image"] { filter: invert(1) hue-rotate(180deg) !important; } /* Preserve specific elements that should not be inverted */ .no-dark-mode, .no-dark-mode *, [data-theme="light"], [data-theme="light"], .ace_editor, .ace_editor *, .CodeMirror, .CodeMirror *, .monaco-editor, .monaco-editor *, .markdown-body pre, .markdown-body pre *, .highlight, .highlight *, pre code, pre code * { filter: none !important; } /* Fix common UI elements */ .modal, .popup, .dropdown-menu, .tooltip, .popover { filter: invert(1) hue-rotate(180deg) !important; background: #2d2d44 !important; border-color: #444 !important; } /* Scrollbars */ ::-webkit-scrollbar { background: #1a1a2e !important; } ::-webkit-scrollbar-thumb { background: #444 !important; } ::-webkit-scrollbar-thumb:hover { background: #555 !important; } /* Selection */ ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; } ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; } '; } function removeDarkMode() { var style = document.getElementById('universal-dark-mode-style'); if (style) style.remove(); } // Toggle with Alt+Shift+D document.addEventListener('keydown', function(e) { if (e.altKey && e.shiftKey && e.key === 'D') { e.preventDefault(); enabled = !enabled; if (enabled) { applyDarkMode(); console.log('[Universal Dark Mode] Enabled'); } else { removeDarkMode(); console.log('[Universal Dark Mode] Disabled'); } } }); // Apply on load applyDarkMode(); // Re-apply on dynamic content var observer = new MutationObserver(function(mutations) { if (enabled && !document.getElementById('universal-dark-mode-style')) { applyDarkMode(); } }); observer.observe(document.head, { childList: true }); console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle'); })(); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })(); Native forward: supportsForward SDK flag + Gmail message forwarding by KrisBraun · Pull Request #258 · plotday/plot · GitHub
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/forward-a-note.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
---
"@plotday/twister": minor
---

Added: `LinkTypeConfig.supportsForward` and `CreateLinkDraft.forward` so connectors can perform native forwards of existing items (e.g. email forwards carrying the original message and attachments) when a user forwards a note.
4 changes: 4 additions & 0 deletions connectors/gmail/src/channels.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -44,6 +44,10 @@ export const GMAIL_LINK_TYPES: LinkTypeConfig[] = [
compose: {
targets: "addresses" as const,
},
// onCreateLink reconstructs a real Gmail forward (original message +
// attachments) when `CreateLinkDraft.forward` is set, so the runtime
// should route native forwards here instead of the blockquote fallback.
supportsForward: true,
},
];

Expand Down
76 changes: 76 additions & 0 deletions connectors/gmail/src/gmail-api.test.ts
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import {
GmailApi,
buildForwardMessage,
buildNewEmailMessage,
buildReplyMessage,
stripQuotedReply,
Expand DownExpand Up@@ -319,3 +320,78 @@ describe("outbound MIME bodies (multipart/alternative HTML + plain)", () => {
expect(decodeMimePart(raw, "text/plain")).not.toContain("**");
});
});

describe("buildForwardMessage", () => {
it("uses a Fwd: subject and includes the quoted original + forwarder message", () => {
const raw = decodeRawMessage(
buildForwardMessage({
to: ["bob@example.com"],
cc: [],
from: "me@example.com",
subject: "Q3 budget",
body: "See below.",
originalHeader: "From: Alice <alice@example.com>\nSubject: Q3 budget",
originalBody: "Let's meet Thursday.",
})
);
expect(raw).toContain("Subject: Fwd: Q3 budget");
expect(raw).not.toContain("In-Reply-To:");

// Body content lives base64-encoded inside the multipart/alternative
// plain-text part, so decode that part rather than scanning the raw
// (still-encoded) message for the literal text.
const text = decodeMimePart(raw, "text/plain");
expect(text).toContain("See below.");
expect(text).toContain("Let's meet Thursday.");
});

it("keeps an existing Fwd: prefix instead of doubling it", () => {
const raw = decodeRawMessage(
buildForwardMessage({
to: ["b@x.com"],
cc: [],
from: "m@x.com",
subject: "Fwd: hi",
body: "",
originalHeader: "From: a@x.com",
originalBody: "hi",
})
);
expect(raw).toContain("Subject: Fwd: hi");
expect(raw).not.toContain("Fwd: Fwd:");
});

it("emits a Bcc header without exposing bcc addresses in To/Cc", () => {
const raw = decodeRawMessage(
buildForwardMessage({
to: ["bob@example.com"],
cc: ["carol@example.com"],
bcc: ["dave@example.com"],
from: "me@example.com",
subject: "Q3 budget",
body: "",
originalHeader: "From: Alice <alice@example.com>",
originalBody: "hi",
})
);
expect(raw).toContain("Bcc: dave@example.com");
expect(raw).toContain("To: bob@example.com");
expect(raw).not.toContain("To: bob@example.com, dave@example.com");
expect(raw).not.toContain("Cc: carol@example.com, dave@example.com");
});

it("omits the Bcc header entirely when there are no bcc recipients", () => {
const raw = decodeRawMessage(
buildForwardMessage({
to: ["bob@example.com"],
cc: [],
from: "me@example.com",
subject: "Q3 budget",
body: "",
originalHeader: "From: Alice <alice@example.com>",
originalBody: "hi",
})
);
expect(raw).not.toContain("Bcc:");
});
});
130 changes: 129 additions & 1 deletion connectors/gmail/src/gmail-api.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -283,6 +283,21 @@ export class GmailApi {
return data as GmailThread;
}

/**
* Fetches a single message by id. Used to source a native forward (see
* `buildForwardMessage`), where the caller has a message id — the note
* `key` a Gmail-backed note carries — rather than a thread id.
*/
public async getMessage(
messageId: string,
format: "full" | "minimal" | "metadata" | "raw" = "full"
): Promise<GmailMessage> {
const data = await this.call(`/messages/${messageId}`, {
params: { format },
});
return data as GmailMessage;
}

public async setupWatch(
topicName: string,
labelId?: string
Expand DownExpand Up@@ -584,7 +599,7 @@ export function isFromAddressRewritten(
* Extracts the body from a Gmail message (handles multipart messages).
* Returns raw content with its type so HTML can be converted server-side.
*/
function extractBody(part: GmailMessagePart): { content: string; contentType: "text" | "html" } {
export function extractBody(part: GmailMessagePart): { content: string; contentType: "text" | "html" } {
// Prefer HTML over plain text — server-side conversion produces cleaner output.
// Search the WHOLE MIME tree, not just immediate children: forwarded messages
// nest the real body inside a `message/rfc822` part (or a deeper multipart),
Expand DownExpand Up@@ -1457,3 +1472,116 @@ export function buildReplyMessage(options: {

return base64UrlEncodeMessage(rawMessage);
}

/**
* Builds an RFC 2822 forward of an existing Gmail message. Gmail has no
* native "forward" endpoint — a forward is just a new message sent via
* `messages.send`. Unlike a reply, a forward starts a NEW thread (no
* `In-Reply-To` / `References`). The body is the forwarder's own message
* followed by a standard quoted-original attribution block; the original
* message's attachments are re-attached.
* Returns the base64url-encoded raw message string for the Gmail API.
*/
export function buildForwardMessage(options: {
to: string[];
cc: string[];
/**
* Optional — a forward's recipients come from the same role-tagged picker
* as a compose, so a bcc-role recipient can appear here. Defaults to none.
* Gmail's send API delivers to Bcc recipients and strips the Bcc header
* from the copy other recipients receive (same as `buildNewEmailMessage`),
* so listing them here does not expose them.
*/
bcc?: string[];
from: string;
subject: string;
body: string;
originalHeader: string; // e.g. "From: … \n Date: … \n Subject: … \n To: …"
originalBody: string; // the original message's text/markdown body
attachments?: AttachmentData[];
}): string {
const {
to,
cc,
bcc = [],
from,
subject,
body,
originalHeader,
originalBody,
attachments,
} = options;

// Sanitize every value interpolated into a header to prevent CRLF header
// injection (RFC 5322) via attacker-controlled subjects or addresses.
const fromHeader = sanitizeHeaderValue(from);
const toHeader = to.map(sanitizeHeaderValue).join(", ");
const ccHeader = cc.map(sanitizeHeaderValue).join(", ");
const bccHeader = bcc.map(sanitizeHeaderValue).join(", ");

// Ensure subject has a "Fwd:" prefix, without doubling an existing one.
const fwdSubject = sanitizeHeaderValue(
subject.startsWith("Fwd:") ? subject : `Fwd: ${subject}`
);

// A forward starts a new thread, so — unlike buildReplyMessage — there is
// no In-Reply-To / References header here.
const headerLines: string[] = [`From: ${fromHeader}`, `To: ${toHeader}`];
if (cc.length > 0) headerLines.push(`Cc: ${ccHeader}`);
if (bcc.length > 0) headerLines.push(`Bcc: ${bccHeader}`);
headerLines.push(`Subject: ${fwdSubject}`);
headerLines.push(`MIME-Version: 1.0`);

// Compose the visible body: the forwarder's own message (if any) on top of
// a standard quoted-original attribution block.
const quotedOriginal = [
"---------- Forwarded message ----------",
originalHeader,
"",
originalBody,
].join("\n");
const composed = body.length > 0 ? `${body}\n\n${quotedOriginal}` : quotedOriginal;

// 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, composed);

let rawMessage: string;

if (attachments && attachments.length > 0) {
// Wrap the alternative body and the re-attached attachments in a
// multipart/mixed.
const mixBoundary = mimeBoundary("mix");

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(
`--${mixBoundary}`,
`Content-Type: ${att.mimeType}; name="${safeFileName}"`,
`Content-Transfer-Encoding: base64`,
`Content-Disposition: attachment; filename="${safeFileName}"`,
"",
b64Lines,
);
}

rawMessage = [
...headerLines,
`Content-Type: multipart/mixed; boundary="${mixBoundary}"`,
"", // end of message headers
`--${mixBoundary}`,
...altBlock,
...attachmentParts,
`--${mixBoundary}--`,
].join("\r\n");
} else {
// Top-level body is the multipart/alternative entity itself.
rawMessage = [...headerLines, ...altBlock].join("\r\n");
}

return base64UrlEncodeMessage(rawMessage);
}
Loading
Loading