postlink.js 7.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234
  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. console.log("🔎 uploadRes.data:", JSON.stringify(uploadRes.data, null, 2));
  61. const {
  62. url: uploadUrl,
  63. url_signature: fileUrlSignature
  64. } = uploadRes.data.data.attributes;
  65. // upload pdf
  66. const fileBuffer = fs.readFileSync(pdfPath);
  67. await axios.put(uploadUrl, fileBuffer, {
  68. headers: { 'Content-Type': 'application/pdf' }
  69. });
  70. // create mail object
  71. const response = await axios.post(
  72. `https://api.pingen.com/organisations/${config.pingen.organisation_id}/letters`,
  73. {
  74. data: {
  75. type: 'letters',
  76. attributes: {
  77. file_original_name: path.basename(pdfPath),
  78. file_url: uploadUrl,
  79. file_url_signature: fileUrlSignature,
  80. address_position: 'left',
  81. auto_send: true,
  82. delivery_product: 'cheap',
  83. print_mode: 'duplex',
  84. print_spectrum: 'color'
  85. }
  86. }
  87. },
  88. {
  89. headers: {
  90. 'Content-Type': 'application/vnd.api+json',
  91. Authorization: `Bearer ${token}`
  92. }
  93. }
  94. );
  95. console.log(`✅ Brief bei Pingen angelegt: ${response.data.data.id}`);
  96. }
  97. const config = yaml.parse(fs.readFileSync('config.yaml', 'utf8'));
  98. const imapConfig = {
  99. imap: {
  100. user: config.imap.user,
  101. password: config.imap.password,
  102. host: config.imap.host,
  103. port: config.imap.port,
  104. tls: config.imap.tls,
  105. authTimeout: 3000
  106. }
  107. };
  108. (async () => {
  109. const connection = await imaps.connect(imapConfig);
  110. await connection.openBox('INBOX');
  111. const searchCriteria = ['ALL'];
  112. const fetchOptions = { bodies: ['HEADER.FIELDS (FROM TO SUBJECT DATE)', 'TEXT'], struct: true };
  113. const messages = await connection.search(searchCriteria, fetchOptions);
  114. if (messages.length === 0) {
  115. console.log('No emails found. Exiting...');
  116. await connection.end();
  117. return;
  118. }
  119. const latest = messages[messages.length - 1];
  120. const isSeen = latest.attributes.flags.includes('\\Seen');
  121. if (isSeen) {
  122. console.log('Last email in inbox already marked as read. Exiting...');
  123. await connection.end();
  124. return;
  125. }
  126. const qp = require('quoted-printable');
  127. const { JSDOM } = require('jsdom');
  128. const rawBody = latest.parts.find(part => part.which === 'TEXT').body;
  129. // decode quoted-printable S/MIME email html body
  130. const decodedBody = qp.decode(rawBody);
  131. // converted into readable html object
  132. const dom = new JSDOM(decodedBody);
  133. const links = [...dom.window.document.querySelectorAll('a')]
  134. .map(a => a.href)
  135. .filter(href => href && href.includes('mailchi.mp'));
  136. const mailchimpLink = links[0];
  137. if (!mailchimpLink) {
  138. await connection.end();
  139. console.log('No Mailchimp link found. Exiting...');
  140. return;
  141. }
  142. console.log('Mailchimp link found:', mailchimpLink);
  143. await connection.addFlags(latest.attributes.uid, '\\Seen');
  144. await connection.end();
  145. const browser = await puppeteer.launch();
  146. const page = await browser.newPage();
  147. await page.goto(mailchimpLink, { waitUntil: 'networkidle0' });
  148. const htmlContent = await page.content();
  149. fs.writeFileSync(ORIGINAL_HTML_FILE, htmlContent, 'utf-8');
  150. // apply sed command to html file
  151. fs.writeFileSync('sed_script.sed', config.generate.sed_options, 'utf-8');
  152. const sedCmd = `sed -f sed_script.sed ${ORIGINAL_HTML_FILE} > ${CLEANED_HTML_FILE}`;
  153. execSync(sedCmd, { shell: '/bin/bash' });
  154. // load the new html after the sed command is applied
  155. const fileUrl = 'file://' + path.resolve(CLEANED_HTML_FILE);
  156. await page.goto(fileUrl, { waitUntil: 'networkidle0' });
  157. // apply css styling
  158. await page.addStyleTag({
  159. content: config.generate.css_styling
  160. });
  161. await page.pdf({
  162. path: GENERAL_PDF_FILE,
  163. format: 'A4',
  164. margin: { top: '6mm', bottom: '6mm', left: '6mm', right: '6mm' },
  165. printBackground: false
  166. });
  167. await browser.close();
  168. await trimPdfToMaxPages(GENERAL_PDF_FILE, GENERAL_PDF_FILE, config.generate.pdf_max_pages);
  169. console.log('general pdf successfully generated.');
  170. if (dryRun) {
  171. console.log('Dry run. Exiting...')
  172. } else {
  173. execSync(config.upload.cmd, { shell: '/bin/bash' });
  174. const mergerTasks = config.cover_letters || [];
  175. // add cover letters to the general pdf file
  176. for (const coverPath of mergerTasks) {
  177. const name = path.basename(coverPath, '.pdf');
  178. const outputFile = `infomail-${name}.pdf`;
  179. const merger = new PDFMerger();
  180. await merger.add(path.resolve(coverPath));
  181. await merger.add(path.resolve(GENERAL_PDF_FILE));
  182. await merger.save(outputFile);
  183. console.log(`pdf created: '${outputFile}'.`);
  184. await uploadToPingen(outputFile, config);
  185. }
  186. }
  187. })();