postlink.js 7.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233
  1. /* postlink: fully automate printing and sending an email as physical post
  2. Copyright © 2025 Noah Vogt noah@noahvogt.com
  3. This program is free software: you can redistribute it and/or modify
  4. it under the terms of the GNU General Public License as published by
  5. the Free Software Foundation, either version 3 of the License, or
  6. (at your option) any later version.
  7. This program is distributed in the hope that it will be useful,
  8. but WITHOUT ANY WARRANTY; without even the implied warranty of
  9. MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
  10. GNU General Public License for more details.
  11. You should have received a copy of the GNU General Public License
  12. along with this program. If not, see <http://www.gnu.org/licenses/>. */
  13. const fs = require('fs');
  14. const yaml = require('yaml');
  15. const imaps = require('imap-simple');
  16. const { JSDOM } = require('jsdom');
  17. const puppeteer = require('puppeteer');
  18. const path = require('path');
  19. const { execSync } = require('child_process');
  20. const PDFMerger = require('pdf-merger-js').default;
  21. const { PDFDocument } = require('pdf-lib');
  22. const axios = require('axios');
  23. const ORIGINAL_HTML_FILE = 'original-infomail.html';
  24. const CLEANED_HTML_FILE = 'cleaned-infomail.html';
  25. const GENERAL_PDF_FILE = 'infomail.pdf';
  26. const dryRun = process.argv.includes('--dry-run');
  27. async function trimPdfToMaxPages(inputPath, outputPath, maxPages) {
  28. const fileBuffer = fs.readFileSync(inputPath);
  29. const pdfDoc = await PDFDocument.load(fileBuffer);
  30. const totalPages = pdfDoc.getPageCount();
  31. if (totalPages <= maxPages) {
  32. return;
  33. }
  34. const trimmedPdf = await PDFDocument.create();
  35. const copiedPages = await trimmedPdf.copyPages(pdfDoc, [...Array(maxPages).keys()]);
  36. copiedPages.forEach(p => trimmedPdf.addPage(p));
  37. const trimmedBytes = await trimmedPdf.save();
  38. fs.writeFileSync(outputPath, trimmedBytes);
  39. console.log(`pdf shorted to ${maxPages} pages.`);
  40. }
  41. async function uploadToPingen(pdfPath, config) {
  42. console.log(`uploading '${pdfPath}' to pingen.com api...`);
  43. // get access token
  44. const tokenRes = await axios.post(
  45. 'https://identity.pingen.com/auth/access-tokens',
  46. new URLSearchParams({
  47. grant_type: 'client_credentials',
  48. client_id: config.pingen.client_id,
  49. client_secret: config.pingen.client_secret
  50. }),
  51. {
  52. headers: { 'Content-Type': 'application/x-www-form-urlencoded' }
  53. }
  54. );
  55. const token = tokenRes.data.access_token;
  56. // get upload url + signature
  57. const uploadRes = await axios.get('https://api.pingen.com/file-upload', {
  58. headers: { Authorization: `Bearer ${token}` }
  59. });
  60. const {
  61. url: uploadUrl,
  62. url_signature: fileUrlSignature
  63. } = uploadRes.data.data.attributes;
  64. // upload pdf
  65. const fileBuffer = fs.readFileSync(pdfPath);
  66. await axios.put(uploadUrl, fileBuffer, {
  67. headers: { 'Content-Type': 'application/pdf' }
  68. });
  69. // create mail object
  70. const response = await axios.post(
  71. `https://api.pingen.com/organisations/${config.pingen.organisation_id}/letters`,
  72. {
  73. data: {
  74. type: 'letters',
  75. attributes: {
  76. file_original_name: path.basename(pdfPath),
  77. file_url: uploadUrl,
  78. file_url_signature: fileUrlSignature,
  79. address_position: 'left',
  80. auto_send: true,
  81. delivery_product: 'cheap',
  82. print_mode: 'duplex',
  83. print_spectrum: 'color'
  84. }
  85. }
  86. },
  87. {
  88. headers: {
  89. 'Content-Type': 'application/vnd.api+json',
  90. Authorization: `Bearer ${token}`
  91. }
  92. }
  93. );
  94. console.log(`created pingen.com mail object: ${response.data.data.id}`);
  95. }
  96. const config = yaml.parse(fs.readFileSync('config.yaml', 'utf8'));
  97. const imapConfig = {
  98. imap: {
  99. user: config.imap.user,
  100. password: config.imap.password,
  101. host: config.imap.host,
  102. port: config.imap.port,
  103. tls: config.imap.tls,
  104. authTimeout: 3000
  105. }
  106. };
  107. (async () => {
  108. const connection = await imaps.connect(imapConfig);
  109. await connection.openBox('INBOX');
  110. const searchCriteria = ['ALL'];
  111. const fetchOptions = { bodies: ['HEADER.FIELDS (FROM TO SUBJECT DATE)', 'TEXT'], struct: true };
  112. const messages = await connection.search(searchCriteria, fetchOptions);
  113. if (messages.length === 0) {
  114. console.log('No emails found. Exiting...');
  115. await connection.end();
  116. return;
  117. }
  118. const latest = messages[messages.length - 1];
  119. const isSeen = latest.attributes.flags.includes('\\Seen');
  120. if (isSeen) {
  121. console.log('Last email in inbox already marked as read. Exiting...');
  122. await connection.end();
  123. return;
  124. }
  125. const qp = require('quoted-printable');
  126. const { JSDOM } = require('jsdom');
  127. const rawBody = latest.parts.find(part => part.which === 'TEXT').body;
  128. // decode quoted-printable S/MIME email html body
  129. const decodedBody = qp.decode(rawBody);
  130. // converted into readable html object
  131. const dom = new JSDOM(decodedBody);
  132. const links = [...dom.window.document.querySelectorAll('a')]
  133. .map(a => a.href)
  134. .filter(href => href && href.includes('mailchi.mp'));
  135. const mailchimpLink = links[0];
  136. if (!mailchimpLink) {
  137. await connection.end();
  138. console.log('No Mailchimp link found. Exiting...');
  139. return;
  140. }
  141. console.log('Mailchimp link found:', mailchimpLink);
  142. await connection.addFlags(latest.attributes.uid, '\\Seen');
  143. await connection.end();
  144. const browser = await puppeteer.launch();
  145. const page = await browser.newPage();
  146. await page.goto(mailchimpLink, { waitUntil: 'networkidle0' });
  147. const htmlContent = await page.content();
  148. fs.writeFileSync(ORIGINAL_HTML_FILE, htmlContent, 'utf-8');
  149. // apply sed command to html file
  150. fs.writeFileSync('sed_script.sed', config.generate.sed_options, 'utf-8');
  151. const sedCmd = `sed -f sed_script.sed ${ORIGINAL_HTML_FILE} > ${CLEANED_HTML_FILE}`;
  152. execSync(sedCmd, { shell: '/bin/bash' });
  153. // load the new html after the sed command is applied
  154. const fileUrl = 'file://' + path.resolve(CLEANED_HTML_FILE);
  155. await page.goto(fileUrl, { waitUntil: 'networkidle0' });
  156. // apply css styling
  157. await page.addStyleTag({
  158. content: config.generate.css_styling
  159. });
  160. await page.pdf({
  161. path: GENERAL_PDF_FILE,
  162. format: 'A4',
  163. margin: { top: '6mm', bottom: '6mm', left: '6mm', right: '6mm' },
  164. printBackground: false
  165. });
  166. await browser.close();
  167. await trimPdfToMaxPages(GENERAL_PDF_FILE, GENERAL_PDF_FILE, config.generate.pdf_max_pages);
  168. console.log('general pdf successfully generated.');
  169. if (dryRun) {
  170. console.log('Dry run. Exiting...')
  171. } else {
  172. execSync(config.upload.cmd, { shell: '/bin/bash' });
  173. const mergerTasks = config.cover_letters || [];
  174. // add cover letters to the general pdf file
  175. for (const coverPath of mergerTasks) {
  176. const name = path.basename(coverPath, '.pdf');
  177. const outputFile = `infomail-${name}.pdf`;
  178. const merger = new PDFMerger();
  179. await merger.add(path.resolve(coverPath));
  180. await merger.add(path.resolve(GENERAL_PDF_FILE));
  181. await merger.save(outputFile);
  182. console.log(`pdf created: '${outputFile}'.`);
  183. await uploadToPingen(outputFile, config);
  184. }
  185. }
  186. })();