Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
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
14 changes: 14 additions & 0 deletions src/gmail-to-drive-by-labels/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -86,11 +86,25 @@ function getProcessConfig() {

## Usage

### Regular Processing

1. Select `storeEmailsAndAttachments` from the function dropdown in the Apps Script toolbar.
2. Click **Run**.
3. Grant permissions when prompted (access to Gmail, Drive, and Docs).
4. Check the **Execution Log** for progress.

### Rebuilding Documents

If you've updated the cleaning logic (e.g., `getCleanBody` function) or want to regenerate documents with new processing rules:

1. Select `rebuildAllDocs` from the function dropdown in the Apps Script toolbar.
2. Click **Run** - this will:
* Clear all configured Google Docs
* Move all processed/archived emails back to their trigger labels
3. Then run `storeEmailsAndAttachments` to reprocess all emails with the updated logic.

**Note:** The rebuild process moves (not copies) emails back to trigger labels, ensuring all emails are reprocessed exactly once with the latest logic while maintaining incremental processing to avoid script timeouts.

## Automation (Optional)

To run this script automatically (e.g., every hour):
Expand Down
91 changes: 91 additions & 0 deletions src/gmail-to-drive-by-labels/code.gs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,92 @@ function storeEmailsAndAttachments() {
console.log('[storeEmailsAndAttachments] Completed all processing');
}

/**
* Rebuilds all configured documents by clearing them and reprocessing all emails.
* This function:
* 1. Clears the configured Google Doc
* 2. Moves all processed/archived emails back to the trigger label
* 3. Allows storeEmailsAndAttachments() to reprocess them
*
* Run this when you've updated getCleanBody() or other processing logic
* and want to regenerate the documents with the new logic.
*/
function rebuildAllDocs() {
console.log('[rebuildAllDocs] Starting rebuild process');
var PROCESS_CONFIG = getProcessConfig();
console.log('[rebuildAllDocs] Rebuilding', PROCESS_CONFIG.length, 'configurations');

PROCESS_CONFIG.forEach((config, index) => {
console.log('[rebuildAllDocs] Rebuilding config', index + 1, 'of', PROCESS_CONFIG.length, ':', config.triggerLabel);
rebuildDoc(config);
});

console.log('[rebuildAllDocs] Rebuild preparation complete.');
console.log('[rebuildAllDocs] Now run storeEmailsAndAttachments() to reprocess all emails.');
}

/**
* Rebuilds a single document by clearing it and moving processed emails back to trigger label.
*/
function rebuildDoc(config) {
console.log('[rebuildDoc] Starting rebuild for:', config.triggerLabel);

var triggerLabelName = config.triggerLabel;
var processedLabelName = config.processedLabel;

// 1. Validate and get labels
console.log('[rebuildDoc] Looking up labels');
var triggerLabel = GmailApp.getUserLabelByName(triggerLabelName);
var processedLabel = GmailApp.getUserLabelByName(processedLabelName);

if (!triggerLabel) {
console.error('[rebuildDoc] Trigger label not found:', triggerLabelName);
Logger.log("Trigger label not found: " + triggerLabelName);
return;
}

if (!processedLabel) {
console.log('[rebuildDoc] Processed label not found:', processedLabelName, '- nothing to unarchive');
}

// 2. Clear the document
console.log('[rebuildDoc] Clearing document:', config.docId);
try {
var doc = DocumentApp.openById(config.docId);
var body = doc.getBody();

// Clear all content from the document body in a single operation
body.setText('');
console.log('[rebuildDoc] Document cleared');
} catch (e) {
console.error('[rebuildDoc] Error clearing document:', e.message);
Logger.log("Error clearing document: " + e.message);
return;
Comment thread
don-petry marked this conversation as resolved.
Outdated
}

// 3. Move emails from processed label back to trigger label
if (processedLabel) {
console.log('[rebuildDoc] Moving processed emails back to trigger label');
var processedThreads = processedLabel.getThreads();
console.log('[rebuildDoc] Found', processedThreads.length, 'processed threads to unarchive');

processedThreads.forEach((thread, index) => {
// Add trigger label and remove processed label
triggerLabel.addToThread(thread);
processedLabel.removeFromThread(thread);

Comment thread
don-petry marked this conversation as resolved.
Outdated
if ((index + 1) % 10 === 0) {
console.log('[rebuildDoc] Moved', index + 1, 'of', processedThreads.length, 'threads');
}
});

console.log('[rebuildDoc] Moved all', processedThreads.length, 'threads back to trigger label');
}

console.log('[rebuildDoc] Rebuild complete for:', config.triggerLabel);
console.log('[rebuildDoc] Run storeEmailsAndAttachments() to reprocess these emails');
}

/**
* Processes a single configuration group (Label -> Doc + Folder).
*/
Expand Down Expand Up @@ -238,3 +324,8 @@ function getFileHash(blob) {
return ('0' + (byte & 0xFF).toString(16)).slice(-2);
}).join('');
}

// Export functions for testing (Node.js only)
if (typeof module !== 'undefined' && module.exports) {
module.exports = { rebuildDoc, rebuildAllDocs, processLabelGroup, storeEmailsAndAttachments };
}
243 changes: 243 additions & 0 deletions src/gmail-to-drive-by-labels/tests/rebuild.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,243 @@
const { createMessage, createBlob } = require('../../../test-utils/mocks');
Comment thread
don-petry marked this conversation as resolved.
Outdated

// Mock the config
global.getProcessConfig = jest.fn(() => [
{
triggerLabel: 'test-label',
processedLabel: 'test-label-archived',
docId: 'doc-1',
folderId: 'folder-1'
}
]);

// Load the code after mocks are set up
const { rebuildDoc, rebuildAllDocs } = require('../code.gs');

describe('rebuildDoc', () => {
beforeEach(() => {
global.GmailApp.__reset();
global.DocumentApp.__reset();
global.DriveApp.__reset();
jest.clearAllMocks();
});

test('clears document and moves emails from processed to trigger label', () => {
// Setup: Create labels
const triggerLabel = global.GmailApp.createLabel('test-label');
const processedLabel = global.GmailApp.createLabel('test-label-archived');

// Setup: Add some processed threads
const msg1 = createMessage({ subject: 'Email 1', body: 'Body 1' });
const msg2 = createMessage({ subject: 'Email 2', body: 'Body 2' });
const thread1 = global.GmailApp.__addThreadWithLabels(['test-label-archived'], [msg1]);
const thread2 = global.GmailApp.__addThreadWithLabels(['test-label-archived'], [msg2]);
Comment thread
don-petry marked this conversation as resolved.
Outdated
Comment thread
don-petry marked this conversation as resolved.
Outdated

// Setup: Create document with content
const doc = global.DocumentApp.openById('doc-1');
const body = doc.getBody();
body.appendParagraph('Old content 1');
body.appendParagraph('Old content 2');
body.appendParagraph('Old content 3');

// Verify initial state
expect(body.getParagraphs().length).toBe(3);
expect(processedLabel.getThreads().length).toBe(2);
expect(triggerLabel.getThreads().length).toBe(0);

// Run rebuild
const config = {
triggerLabel: 'test-label',
processedLabel: 'test-label-archived',
docId: 'doc-1',
folderId: 'folder-1'
};
rebuildDoc(config);

// Verify document is cleared
expect(body.getParagraphs().length).toBe(0);

// Verify emails are moved back to trigger label
expect(triggerLabel.getThreads().length).toBe(2);
expect(processedLabel.getThreads().length).toBe(0);
});

test('handles missing processed label gracefully', () => {
// Setup: Create only trigger label (no processed label)
const triggerLabel = global.GmailApp.createLabel('test-label');
Comment thread
don-petry marked this conversation as resolved.

// Setup: Create document with content
const doc = global.DocumentApp.openById('doc-1');
const body = doc.getBody();
body.appendParagraph('Old content');

// Run rebuild
const config = {
triggerLabel: 'test-label',
processedLabel: 'test-label-archived',
docId: 'doc-1',
folderId: 'folder-1'
};

// Should not throw
expect(() => rebuildDoc(config)).not.toThrow();

// Document should still be cleared
expect(body.getParagraphs().length).toBe(0);
});

test('handles missing trigger label gracefully', () => {
// Setup: Create document
const doc = global.DocumentApp.openById('doc-1');
const body = doc.getBody();
body.appendParagraph('Old content');

// Run rebuild with non-existent trigger label
const config = {
triggerLabel: 'non-existent-label',
processedLabel: 'test-label-archived',
docId: 'doc-1',
folderId: 'folder-1'
};

// Should not throw
expect(() => rebuildDoc(config)).not.toThrow();

// Document should not be cleared (function returns early)
expect(body.getParagraphs().length).toBe(1);
});

test('clears empty document without errors', () => {
// Setup: Create labels
global.GmailApp.createLabel('test-label');

// Setup: Create empty document
const doc = global.DocumentApp.openById('doc-1');
const body = doc.getBody();

// Verify initial state
expect(body.getParagraphs().length).toBe(0);

// Run rebuild
const config = {
triggerLabel: 'test-label',
processedLabel: 'test-label-archived',
docId: 'doc-1',
folderId: 'folder-1'
};

// Should not throw
expect(() => rebuildDoc(config)).not.toThrow();

// Document should still be empty
expect(body.getParagraphs().length).toBe(0);
});

test('moves multiple threads correctly', () => {
// Setup: Create labels
const triggerLabel = global.GmailApp.createLabel('test-label');
const processedLabel = global.GmailApp.createLabel('test-label-archived');

// Setup: Add many processed threads
const threads = [];
for (let i = 0; i < 25; i++) {
const msg = createMessage({ subject: `Email ${i}`, body: `Body ${i}` });
const thread = global.GmailApp.__addThreadWithLabels(['test-label-archived'], [msg]);
threads.push(thread);
}

// Setup: Create document
const doc = global.DocumentApp.openById('doc-1');
const body = doc.getBody();
body.appendParagraph('Content');

// Verify initial state
expect(processedLabel.getThreads().length).toBe(25);
expect(triggerLabel.getThreads().length).toBe(0);

// Run rebuild
const config = {
triggerLabel: 'test-label',
processedLabel: 'test-label-archived',
docId: 'doc-1',
folderId: 'folder-1'
};
rebuildDoc(config);

// Verify all threads are moved
expect(triggerLabel.getThreads().length).toBe(25);
expect(processedLabel.getThreads().length).toBe(0);
});
});

describe('rebuildAllDocs', () => {
beforeEach(() => {
global.GmailApp.__reset();
global.DocumentApp.__reset();
global.DriveApp.__reset();
jest.clearAllMocks();
});

test('rebuilds all configured documents', () => {
// Setup multiple configs
global.getProcessConfig.mockReturnValue([
{
triggerLabel: 'label-1',
processedLabel: 'label-1-archived',
docId: 'doc-1',
folderId: 'folder-1'
},
{
triggerLabel: 'label-2',
processedLabel: 'label-2-archived',
docId: 'doc-2',
folderId: 'folder-2'
}
]);

// Setup: Create labels and documents
global.GmailApp.createLabel('label-1');
global.GmailApp.createLabel('label-1-archived');
global.GmailApp.createLabel('label-2');
global.GmailApp.createLabel('label-2-archived');

const doc1 = global.DocumentApp.openById('doc-1');
const doc2 = global.DocumentApp.openById('doc-2');

doc1.getBody().appendParagraph('Doc 1 content');
doc2.getBody().appendParagraph('Doc 2 content');

// Verify initial state
expect(doc1.getBody().getParagraphs().length).toBe(1);
expect(doc2.getBody().getParagraphs().length).toBe(1);

// Run rebuild all
rebuildAllDocs();

// Verify both documents are cleared
expect(doc1.getBody().getParagraphs().length).toBe(0);
expect(doc2.getBody().getParagraphs().length).toBe(0);
});

test('handles single configuration', () => {
// Setup single config (default mock)
global.getProcessConfig.mockReturnValue([
{
triggerLabel: 'test-label',
processedLabel: 'test-label-archived',
docId: 'doc-1',
folderId: 'folder-1'
}
]);

// Setup: Create label and document
global.GmailApp.createLabel('test-label');
const doc = global.DocumentApp.openById('doc-1');
doc.getBody().appendParagraph('Content');

// Run rebuild all
expect(() => rebuildAllDocs()).not.toThrow();

// Verify document is cleared
expect(doc.getBody().getParagraphs().length).toBe(0);
});
});
Loading
Loading