SHOW:
|
|
- or go back to the newest paste.
| 1 | /* | |
| 2 | - | Polymarket Commission Email Filter - Google Apps Script |
| 2 | + | Polymarket Commission Email Filter v2 - Google Apps Script |
| 3 | ||
| 4 | - | Archives matching commission emails strictly below LIMIT_DOLLARS. |
| 4 | + | |
| 5 | - | The default threshold is $10; change it to your preferred amount. |
| 5 | + | 1. Create a project at https://script.google.com/ and paste this entire file into Code.gs. |
| 6 | - | Messages remain available in Gmail's All Mail. |
| 6 | + | 2. Set LIMIT_DOLLARS to your preferred threshold (default: $10). |
| 7 | 3. In the editor, go to Services > + > Gmail API > Add. | |
| 8 | 4. Run archiveSmallCommissions once and authorize Gmail access. | |
| 9 | - | 1. Create a project at https://script.google.com/ and paste this code into Code.gs. |
| 9 | + | 5. Add one hourly time-driven trigger for archiveSmallCommissions. |
| 10 | - | 2. Set LIMIT_DOLLARS to your preferred threshold. |
| 10 | + | If you already have this trigger, keep it instead of adding a duplicate. |
| 11 | 6. For a standard Google Cloud project, also enable the Gmail API in its console. | |
| 12 | ||
| 13 | - | 5. Go to Triggers > Add Trigger, select archiveSmallCommissions, |
| 13 | + | BEHAVIOR |
| 14 | - | choose Time-driven, and set your preferred schedule. |
| 14 | + | - Archives matching inbox messages with commissions strictly below the threshold. |
| 15 | - | 6. If you use a standard Google Cloud project, also enable the Gmail API |
| 15 | + | - Reads the sender and subject; leaves message bodies unread by this script. |
| 16 | - | in that project's Google Cloud console. |
| 16 | + | - Inspects at most 100 messages per run, with at least one second between API calls. |
| 17 | - Uses a hidden Gmail label to remember inspected messages, including ones kept | |
| 18 | in the inbox. Changing the threshold creates a new marker for future checks. | |
| 19 | - Retries temporary API failures with increasing delays, then defers remaining | |
| 20 | work to the next scheduled run. Other errors remain visible as failures. | |
| 21 | - Stops early to leave time before the Apps Script execution limit. | |
| 22 | - To inspect a marked message again at the same threshold, remove its | |
| 23 | PolymarketChecked_v2_<threshold in cents> label in Gmail. | |
| 24 | - This code does not send email or transmit mailbox data to third-party services. | |
| 25 | */ | |
| 26 | ||
| 27 | - | // Collect messages first, then archive them: |
| 27 | + | |
| 28 | - | // this prevents inbox changes from affecting pagination. |
| 28 | + | |
| 29 | - | const messageIds = []; |
| 29 | + | const MAX_MESSAGES_PER_RUN = 100; |
| 30 | - | let pageToken; |
| 30 | + | const MIN_API_INTERVAL_MS = 1000; |
| 31 | const MAX_RUN_TIME_MS = 240000; | |
| 32 | - | do {
|
| 32 | + | const MAX_API_RETRIES = 4; |
| 33 | - | const options = {
|
| 33 | + | |
| 34 | - | q: `in:inbox from:${DUB_SENDER} subject:commission subject:Polymarket`,
|
| 34 | + | |
| 35 | - | maxResults: 500 |
| 35 | + | const limitCents = Math.round(LIMIT_DOLLARS * 100); |
| 36 | - | }; |
| 36 | + | if (typeof LIMIT_DOLLARS !== 'number' || !Number.isSafeInteger(limitCents) || limitCents < 0) {
|
| 37 | - | if (pageToken) options.pageToken = pageToken; |
| 37 | + | throw new Error('LIMIT_DOLLARS must be a non-negative dollar amount.');
|
| 38 | } | |
| 39 | - | const page = Gmail.Users.Messages.list('me', options);
|
| 39 | + | |
| 40 | - | for (const message of page.messages || []) {
|
| 40 | + | |
| 41 | - | messageIds.push(message.id); |
| 41 | + | |
| 42 | ||
| 43 | - | pageToken = page.nextPageToken; |
| 43 | + | const deadline = Date.now() + MAX_RUN_TIME_MS; |
| 44 | - | } while (pageToken); |
| 44 | + | const pause = new Error('Resume remaining work on the next scheduled run.');
|
| 45 | let nextCallAt = 0; | |
| 46 | - | let archived = 0; |
| 46 | + | let checked = 0; |
| 47 | let archived = 0; | |
| 48 | - | for (const id of messageIds) {
|
| 48 | + | |
| 49 | - | const message = Gmail.Users.Messages.get('me', id, {
|
| 49 | + | // Pace every API attempt, including retries and label operations. |
| 50 | - | format: 'metadata', |
| 50 | + | function callGmail(request) {
|
| 51 | - | metadataHeaders: ['Subject', 'From'] |
| 51 | + | for (let attempt = 0; attempt <= MAX_API_RETRIES; attempt++) {
|
| 52 | - | }); |
| 52 | + | const wait = Math.max(0, nextCallAt - Date.now()); |
| 53 | if (Date.now() + wait + 10000 >= deadline) throw pause; | |
| 54 | if (wait > 0) Utilities.sleep(wait); | |
| 55 | nextCallAt = Date.now() + MIN_API_INTERVAL_MS; | |
| 56 | - | const headers = message.payload.headers || []; |
| 56 | + | |
| 57 | try {
| |
| 58 | return request(); | |
| 59 | } catch (error) {
| |
| 60 | - | // Double-check the sender's exact email address. |
| 60 | + | const text = String(error && error.message || error); |
| 61 | const temporary = /quota exceeded|rate.?limit.?exceeded|too many requests|\b429\b|backend.?error|service unavailable|internal error|\b50[0234]\b/i.test(text); | |
| 62 | if (!temporary) throw error; | |
| 63 | - | if (address.trim().toLowerCase() !== DUB_SENDER) continue; |
| 63 | + | if (attempt === MAX_API_RETRIES) throw pause; |
| 64 | ||
| 65 | - | // Read only the commission amount from the subject, |
| 65 | + | const delay = Math.min(32000, 2000 * Math.pow(2, attempt)) + |
| 66 | - | // not the purchase amount from the message body. |
| 66 | + | Math.floor(Math.random() * 1000); |
| 67 | - | const subject = getHeader('subject').trim();
|
| 67 | + | if (Date.now() + delay + 10000 >= deadline) throw pause; |
| 68 | - | const match = subject.match( |
| 68 | + | Utilities.sleep(delay); |
| 69 | - | /^You['’]ve made a \$([0-9]+(?:,[0-9]{3})*)(?:\.([0-9]{2}))? commission from Polymarket$/i
|
| 69 | + | |
| 70 | - | ); |
| 70 | + | |
| 71 | } | |
| 72 | - | // Leave messages with unrecognized subjects unchanged. |
| 72 | + | |
| 73 | - | if (!match) continue; |
| 73 | + | function wasRemoved(error) {
|
| 74 | return /\b404\b|not found|requested entity was not found/i.test( | |
| 75 | - | const cents = |
| 75 | + | String(error && error.message || error) |
| 76 | - | Number(match[1].replace(/,/g, '')) * 100 + |
| 76 | + | ); |
| 77 | - | Number(match[2] || '0'); |
| 77 | + | |
| 78 | ||
| 79 | - | if (!Number.isSafeInteger(cents)) continue; |
| 79 | + | |
| 80 | const labelName = 'PolymarketChecked_v2_' + limitCents; | |
| 81 | - | if (cents < LIMIT_DOLLARS * 100) {
|
| 81 | + | const labels = callGmail(() => Gmail.Users.Labels.list('me')).labels || [];
|
| 82 | - | // Removing the INBOX label archives the message. |
| 82 | + | let marker = labels.find(label => label.name === labelName); |
| 83 | - | Gmail.Users.Messages.modify( |
| 83 | + | |
| 84 | - | { removeLabelIds: ['INBOX'] },
|
| 84 | + | if (!marker) {
|
| 85 | - | 'me', |
| 85 | + | marker = callGmail(() => Gmail.Users.Labels.create({
|
| 86 | - | id |
| 86 | + | name: labelName, |
| 87 | labelListVisibility: 'labelHide', | |
| 88 | - | archived++; |
| 88 | + | messageListVisibility: 'hide' |
| 89 | }, 'me')); | |
| 90 | } | |
| 91 | ||
| 92 | - | console.log(`Messages archived: ${archived}`);
|
| 92 | + | // Read one bounded page before modifying any messages. The marker excludes |
| 93 | // completed work on the next run, so there is no shifting-page pagination. | |
| 94 | const page = callGmail(() => Gmail.Users.Messages.list('me', {
| |
| 95 | q: `in:inbox from:${DUB_SENDER} subject:commission subject:Polymarket -label:${labelName}`,
| |
| 96 | maxResults: MAX_MESSAGES_PER_RUN | |
| 97 | })); | |
| 98 | ||
| 99 | for (const item of page.messages || []) {
| |
| 100 | if (Date.now() + 15000 >= deadline) throw pause; | |
| 101 | ||
| 102 | let message; | |
| 103 | try {
| |
| 104 | message = callGmail(() => Gmail.Users.Messages.get('me', item.id, {
| |
| 105 | format: 'metadata', | |
| 106 | metadataHeaders: ['Subject', 'From'] | |
| 107 | })); | |
| 108 | } catch (error) {
| |
| 109 | // A message may have been deleted since the list request. | |
| 110 | if (wasRemoved(error)) continue; | |
| 111 | throw error; | |
| 112 | } | |
| 113 | ||
| 114 | if (!(message.labelIds || []).includes('INBOX')) continue;
| |
| 115 | ||
| 116 | const headers = (message.payload || {}).headers || [];
| |
| 117 | const getHeader = name => | |
| 118 | (headers.find(h => h.name.toLowerCase() === name) || {}).value || '';
| |
| 119 | ||
| 120 | const from = getHeader('from').trim();
| |
| 121 | const address = (from.match(/<([^<>]+)>/) || [null, from])[1]; | |
| 122 | let shouldArchive = false; | |
| 123 | ||
| 124 | // Require the exact sender and a recognized commission subject. | |
| 125 | // Unrecognized emails stay in the inbox and receive only the marker. | |
| 126 | if (address.trim().toLowerCase() === DUB_SENDER) {
| |
| 127 | const match = getHeader('subject').trim().match(
| |
| 128 | /^You['’]ve made a \$([0-9]+(?:,[0-9]{3})*)(?:\.([0-9]{2}))? commission from Polymarket$/i
| |
| 129 | ); | |
| 130 | ||
| 131 | if (match) {
| |
| 132 | const cents = Number(match[1].replace(/,/g, '')) * 100 + | |
| 133 | Number(match[2] || '0'); | |
| 134 | shouldArchive = Number.isSafeInteger(cents) && cents < limitCents; | |
| 135 | } | |
| 136 | } | |
| 137 | ||
| 138 | const changes = { addLabelIds: [marker.id] };
| |
| 139 | if (shouldArchive) changes.removeLabelIds = ['INBOX']; | |
| 140 | ||
| 141 | try {
| |
| 142 | // Archive and record completion in the same idempotent operation. | |
| 143 | callGmail(() => Gmail.Users.Messages.modify(changes, 'me', item.id)); | |
| 144 | } catch (error) {
| |
| 145 | if (wasRemoved(error)) continue; | |
| 146 | throw error; | |
| 147 | } | |
| 148 | ||
| 149 | checked++; | |
| 150 | if (shouldArchive) archived++; | |
| 151 | } | |
| 152 | } catch (error) {
| |
| 153 | if (error !== pause) throw error; | |
| 154 | console.warn('Run paused. Remaining messages will be retried on the next scheduled run.');
| |
| 155 | } finally {
| |
| 156 | console.log(`Messages checked: ${checked}. Messages archived: ${archived}.`);
| |
| 157 | lock.releaseLock(); | |
| 158 | } | |
| 159 | } | |
| 160 |