@aglyn/plugins-crm 1.0.0-beta.218 → 1.0.0-beta.220

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (346) hide show
  1. package/README.md +2 -0
  2. package/package.json +14 -14
  3. package/src/index.d.ts +1 -0
  4. package/src/index.js.map +1 -1
  5. package/src/lib/components/companies-bulk-bar.js +12 -4
  6. package/src/lib/components/companies-bulk-bar.js.map +1 -1
  7. package/src/lib/components/company-detail-page.js +11 -0
  8. package/src/lib/components/company-detail-page.js.map +1 -1
  9. package/src/lib/components/company-edit-drawer.js +12 -2
  10. package/src/lib/components/company-edit-drawer.js.map +1 -1
  11. package/src/lib/components/company-import-drawer.d.ts +1 -1
  12. package/src/lib/components/company-picker.js +10 -3
  13. package/src/lib/components/company-picker.js.map +1 -1
  14. package/src/lib/components/contact-associations-card.js +4 -3
  15. package/src/lib/components/contact-associations-card.js.map +1 -1
  16. package/src/lib/components/contact-detail-page.d.ts +4 -4
  17. package/src/lib/components/contact-detail-page.js +50 -53
  18. package/src/lib/components/contact-detail-page.js.map +1 -1
  19. package/src/lib/components/contact-list-columns.d.ts +3 -0
  20. package/src/lib/components/contact-list-columns.js +21 -5
  21. package/src/lib/components/contact-list-columns.js.map +1 -1
  22. package/src/lib/components/contact-merge-dialog.js +2 -1
  23. package/src/lib/components/contact-merge-dialog.js.map +1 -1
  24. package/src/lib/components/contact-timeline-card.js +4 -3
  25. package/src/lib/components/contact-timeline-card.js.map +1 -1
  26. package/src/lib/components/contacts-bulk-bar.d.ts +3 -3
  27. package/src/lib/components/contacts-bulk-bar.js +24 -51
  28. package/src/lib/components/contacts-bulk-bar.js.map +1 -1
  29. package/src/lib/components/contacts-section.js +9 -3
  30. package/src/lib/components/contacts-section.js.map +1 -1
  31. package/src/lib/components/crm-booking-zone.d.ts +8 -2
  32. package/src/lib/components/crm-booking-zone.js.map +1 -1
  33. package/src/lib/components/crm-export-all-button.d.ts +1 -1
  34. package/src/lib/components/crm-export-all-button.js.map +1 -1
  35. package/src/lib/components/crm-glance-card.js +2 -1
  36. package/src/lib/components/crm-glance-card.js.map +1 -1
  37. package/src/lib/components/crm-send-email-dialog.js +9 -6
  38. package/src/lib/components/crm-send-email-dialog.js.map +1 -1
  39. package/src/lib/components/csv-import-drawer.js +2 -2
  40. package/src/lib/components/csv-import-drawer.js.map +1 -1
  41. package/src/lib/components/deal-detail-page.js +18 -2
  42. package/src/lib/components/deal-detail-page.js.map +1 -1
  43. package/src/lib/components/deal-edit-drawer.js +10 -0
  44. package/src/lib/components/deal-edit-drawer.js.map +1 -1
  45. package/src/lib/components/deal-products-card.js +27 -16
  46. package/src/lib/components/deal-products-card.js.map +1 -1
  47. package/src/lib/components/deals-bulk-bar.js +13 -5
  48. package/src/lib/components/deals-bulk-bar.js.map +1 -1
  49. package/src/lib/components/lead-campaigns-card.d.ts +4 -4
  50. package/src/lib/components/lead-campaigns-card.js +15 -8
  51. package/src/lib/components/lead-campaigns-card.js.map +1 -1
  52. package/src/lib/components/lead-detail-page.js +25 -5
  53. package/src/lib/components/lead-detail-page.js.map +1 -1
  54. package/src/lib/components/lead-properties-card.js +10 -0
  55. package/src/lib/components/lead-properties-card.js.map +1 -1
  56. package/src/lib/components/lead-status-chip.js +3 -1
  57. package/src/lib/components/lead-status-chip.js.map +1 -1
  58. package/src/lib/components/lead-surfaces-note.js +4 -5
  59. package/src/lib/components/lead-surfaces-note.js.map +1 -1
  60. package/src/lib/components/lead-unqualify-dialog.js +6 -0
  61. package/src/lib/components/lead-unqualify-dialog.js.map +1 -1
  62. package/src/lib/components/leads-bulk-bar.js +34 -11
  63. package/src/lib/components/leads-bulk-bar.js.map +1 -1
  64. package/src/lib/components/leads-section.js +41 -8
  65. package/src/lib/components/leads-section.js.map +1 -1
  66. package/src/lib/components/new-lead-drawer.js +5 -4
  67. package/src/lib/components/new-lead-drawer.js.map +1 -1
  68. package/src/lib/components/org-lead-surfaces-note.js +6 -2
  69. package/src/lib/components/org-lead-surfaces-note.js.map +1 -1
  70. package/src/lib/components/recipes-card.js +9 -4
  71. package/src/lib/components/recipes-card.js.map +1 -1
  72. package/src/lib/components/record-sharing-card.d.ts +57 -0
  73. package/src/lib/components/record-sharing-card.js +244 -0
  74. package/src/lib/components/record-sharing-card.js.map +1 -0
  75. package/src/lib/components/reports/activity-card.js +4 -3
  76. package/src/lib/components/reports/activity-card.js.map +1 -1
  77. package/src/lib/components/reports/closed-deals-card.js +4 -3
  78. package/src/lib/components/reports/closed-deals-card.js.map +1 -1
  79. package/src/lib/components/reports/contacts-mix-card.js +3 -2
  80. package/src/lib/components/reports/contacts-mix-card.js.map +1 -1
  81. package/src/lib/components/reports/contacts-trend-card.js +2 -1
  82. package/src/lib/components/reports/contacts-trend-card.js.map +1 -1
  83. package/src/lib/components/reports/forecast-card.js +3 -2
  84. package/src/lib/components/reports/forecast-card.js.map +1 -1
  85. package/src/lib/components/reports/lead-funnel-card.js +8 -6
  86. package/src/lib/components/reports/lead-funnel-card.js.map +1 -1
  87. package/src/lib/components/reports/pipeline-card.js +4 -3
  88. package/src/lib/components/reports/pipeline-card.js.map +1 -1
  89. package/src/lib/components/reports/report-format.d.ts +1 -1
  90. package/src/lib/components/reports/report-format.js.map +1 -1
  91. package/src/lib/components/reports/report-scope.d.ts +1 -1
  92. package/src/lib/components/reports/report-scope.js.map +1 -1
  93. package/src/lib/components/reports/source-conversion-card.js +2 -1
  94. package/src/lib/components/reports/source-conversion-card.js.map +1 -1
  95. package/src/lib/components/reports/tasks-card.js +2 -1
  96. package/src/lib/components/reports/tasks-card.js.map +1 -1
  97. package/src/lib/components/reports/won-lost-by-owner-card.js +3 -2
  98. package/src/lib/components/reports/won-lost-by-owner-card.js.map +1 -1
  99. package/src/lib/components/reports-section.js +4 -4
  100. package/src/lib/components/reports-section.js.map +1 -1
  101. package/src/lib/components/settings-section.js +7 -0
  102. package/src/lib/components/settings-section.js.map +1 -1
  103. package/src/lib/components/share-records-dialog.d.ts +28 -0
  104. package/src/lib/components/share-records-dialog.js +184 -0
  105. package/src/lib/components/share-records-dialog.js.map +1 -0
  106. package/src/lib/components/sharing-rule-drawer.d.ts +37 -0
  107. package/src/lib/components/sharing-rule-drawer.js +374 -0
  108. package/src/lib/components/sharing-rule-drawer.js.map +1 -0
  109. package/src/lib/components/sharing-rules-card.d.ts +24 -0
  110. package/src/lib/components/sharing-rules-card.js +287 -0
  111. package/src/lib/components/sharing-rules-card.js.map +1 -0
  112. package/src/lib/components/use-crm-api.d.ts +1 -1
  113. package/src/lib/components/use-crm-api.js.map +1 -1
  114. package/src/lib/components/use-site-route-context.d.ts +25 -0
  115. package/src/lib/components/use-site-route-context.js +58 -0
  116. package/src/lib/components/use-site-route-context.js.map +1 -0
  117. package/src/lib/constants/api-routes.d.ts +31 -0
  118. package/src/lib/constants/api-routes.js +27 -1
  119. package/src/lib/constants/api-routes.js.map +1 -1
  120. package/src/lib/constants/bundle-common.d.ts +2 -0
  121. package/src/lib/constants/bundle-common.js +1 -0
  122. package/src/lib/constants/bundle-common.js.map +1 -1
  123. package/src/lib/declarations.console-server.d.ts +41 -0
  124. package/src/lib/declarations.console-server.js +115 -0
  125. package/src/lib/declarations.console-server.js.map +1 -0
  126. package/src/lib/declarations.server.d.ts +12 -0
  127. package/src/lib/declarations.server.js +84 -1
  128. package/src/lib/declarations.server.js.map +1 -1
  129. package/src/lib/hooks/use-contact-remove.d.ts +35 -0
  130. package/src/lib/hooks/use-contact-remove.js +77 -0
  131. package/src/lib/hooks/use-contact-remove.js.map +1 -0
  132. package/src/lib/hooks/use-crm-campaigns.d.ts +2 -2
  133. package/src/lib/hooks/use-crm-campaigns.js +3 -3
  134. package/src/lib/hooks/use-crm-campaigns.js.map +1 -1
  135. package/src/lib/hooks/use-crm-sharing.d.ts +30 -0
  136. package/src/lib/hooks/use-crm-sharing.js +109 -0
  137. package/src/lib/hooks/use-crm-sharing.js.map +1 -0
  138. package/src/lib/model/campaign-filing-activity.d.ts +1 -1
  139. package/src/lib/model/campaign-filing-activity.js +1 -1
  140. package/src/lib/model/campaign-filing-activity.js.map +1 -1
  141. package/src/lib/model/companies-csv.d.ts +1 -1
  142. package/src/lib/model/companies-csv.js +2 -2
  143. package/src/lib/model/companies-csv.js.map +1 -1
  144. package/src/lib/model/contact-holder.d.ts +68 -0
  145. package/src/lib/model/contact-holder.js +129 -0
  146. package/src/lib/model/contact-holder.js.map +1 -0
  147. package/src/lib/model/contact-interaction-links.d.ts +46 -0
  148. package/src/lib/model/contact-interaction-links.js +65 -0
  149. package/src/lib/model/contact-interaction-links.js.map +1 -0
  150. package/src/lib/model/contact-merge.d.ts +136 -0
  151. package/src/lib/model/contact-merge.js +412 -0
  152. package/src/lib/model/contact-merge.js.map +1 -0
  153. package/src/lib/model/contact-record.d.ts +7 -1
  154. package/src/lib/model/contact-record.js +7 -6
  155. package/src/lib/model/contact-record.js.map +1 -1
  156. package/src/lib/model/contact-remove.d.ts +48 -0
  157. package/src/lib/model/contact-remove.js +22 -0
  158. package/src/lib/model/contact-remove.js.map +1 -0
  159. package/src/lib/model/contacts-bulk-writes.d.ts +2 -71
  160. package/src/lib/model/contacts-bulk-writes.js +2 -82
  161. package/src/lib/model/contacts-bulk-writes.js.map +1 -1
  162. package/src/lib/model/contacts-csv.d.ts +2 -2
  163. package/src/lib/model/contacts-csv.js +2 -2
  164. package/src/lib/model/contacts-csv.js.map +1 -1
  165. package/src/lib/model/crm-csv.d.ts +239 -0
  166. package/src/lib/model/crm-csv.js +467 -0
  167. package/src/lib/model/crm-csv.js.map +1 -0
  168. package/src/lib/model/crm-digest.d.ts +227 -0
  169. package/src/lib/model/crm-digest.js +336 -0
  170. package/src/lib/model/crm-digest.js.map +1 -0
  171. package/src/lib/model/crm-import.d.ts +7 -1
  172. package/src/lib/model/crm-import.js +2 -1
  173. package/src/lib/model/crm-import.js.map +1 -1
  174. package/src/lib/model/crm-inbound.d.ts +162 -0
  175. package/src/lib/model/crm-inbound.js +208 -0
  176. package/src/lib/model/crm-inbound.js.map +1 -0
  177. package/src/lib/model/crm-lead-import.d.ts +1 -1
  178. package/src/lib/model/crm-lead-import.js +4 -4
  179. package/src/lib/model/crm-lead-import.js.map +1 -1
  180. package/src/lib/model/crm-record-routes.d.ts +5 -0
  181. package/src/lib/model/crm-record-routes.js +7 -0
  182. package/src/lib/model/crm-record-routes.js.map +1 -1
  183. package/src/lib/model/crm-reports.d.ts +467 -0
  184. package/src/lib/model/crm-reports.js +667 -0
  185. package/src/lib/model/crm-reports.js.map +1 -0
  186. package/src/lib/model/crm-routes.d.ts +6 -5
  187. package/src/lib/model/crm-routes.js +7 -7
  188. package/src/lib/model/crm-routes.js.map +1 -1
  189. package/src/lib/model/crm-search-sources.d.ts +15 -0
  190. package/src/lib/model/crm-search-sources.js +162 -0
  191. package/src/lib/model/crm-search-sources.js.map +1 -0
  192. package/src/lib/model/crm-sharing-api.d.ts +32 -0
  193. package/src/lib/model/crm-sharing-api.js +59 -0
  194. package/src/lib/model/crm-sharing-api.js.map +1 -0
  195. package/src/lib/model/crm-sharing.d.ts +333 -0
  196. package/src/lib/model/crm-sharing.js +603 -0
  197. package/src/lib/model/crm-sharing.js.map +1 -0
  198. package/src/lib/model/deals-csv.d.ts +1 -1
  199. package/src/lib/model/deals-csv.js +4 -5
  200. package/src/lib/model/deals-csv.js.map +1 -1
  201. package/src/lib/model/lead-filters.d.ts +5 -4
  202. package/src/lib/model/lead-filters.js +7 -5
  203. package/src/lib/model/lead-filters.js.map +1 -1
  204. package/src/lib/model/leads-csv.d.ts +1 -1
  205. package/src/lib/model/leads-csv.js +1 -1
  206. package/src/lib/model/leads-csv.js.map +1 -1
  207. package/src/lib/model/tasks-csv.d.ts +1 -1
  208. package/src/lib/model/tasks-csv.js +1 -1
  209. package/src/lib/model/tasks-csv.js.map +1 -1
  210. package/src/lib/plan-entitlements.d.ts +75 -0
  211. package/src/lib/plan-entitlements.js +56 -0
  212. package/src/lib/plan-entitlements.js.map +1 -0
  213. package/src/lib/plugin.js +39 -0
  214. package/src/lib/plugin.js.map +1 -1
  215. package/src/lib/server/api-v1/contacts-merge.d.ts +20 -0
  216. package/src/lib/server/api-v1/contacts-merge.js +120 -0
  217. package/src/lib/server/api-v1/contacts-merge.js.map +1 -0
  218. package/src/lib/server/api-v1/contacts.d.ts +18 -0
  219. package/src/lib/server/api-v1/contacts.js +1076 -0
  220. package/src/lib/server/api-v1/contacts.js.map +1 -0
  221. package/src/lib/server/api-v1/crm-activities.d.ts +18 -0
  222. package/src/lib/server/api-v1/crm-activities.js +269 -0
  223. package/src/lib/server/api-v1/crm-activities.js.map +1 -0
  224. package/src/lib/server/api-v1/crm-companies.d.ts +18 -0
  225. package/src/lib/server/api-v1/crm-companies.js +391 -0
  226. package/src/lib/server/api-v1/crm-companies.js.map +1 -0
  227. package/src/lib/server/api-v1/crm-deals.d.ts +18 -0
  228. package/src/lib/server/api-v1/crm-deals.js +631 -0
  229. package/src/lib/server/api-v1/crm-deals.js.map +1 -0
  230. package/src/lib/server/api-v1/crm-email-templates.d.ts +18 -0
  231. package/src/lib/server/api-v1/crm-email-templates.js +336 -0
  232. package/src/lib/server/api-v1/crm-email-templates.js.map +1 -0
  233. package/src/lib/server/api-v1/crm-leads.d.ts +18 -0
  234. package/src/lib/server/api-v1/crm-leads.js +833 -0
  235. package/src/lib/server/api-v1/crm-leads.js.map +1 -0
  236. package/src/lib/server/api-v1/crm-pipelines.d.ts +76 -0
  237. package/src/lib/server/api-v1/crm-pipelines.js +146 -0
  238. package/src/lib/server/api-v1/crm-pipelines.js.map +1 -0
  239. package/src/lib/server/api-v1/crm-shared.d.ts +271 -0
  240. package/src/lib/server/api-v1/crm-shared.js +436 -0
  241. package/src/lib/server/api-v1/crm-shared.js.map +1 -0
  242. package/src/lib/server/api-v1/crm-tasks.d.ts +18 -0
  243. package/src/lib/server/api-v1/crm-tasks.js +398 -0
  244. package/src/lib/server/api-v1/crm-tasks.js.map +1 -0
  245. package/src/lib/server/api-v1/openapi.d.ts +29 -0
  246. package/src/lib/server/api-v1/openapi.js +985 -0
  247. package/src/lib/server/api-v1/openapi.js.map +1 -0
  248. package/src/lib/server/api-v1/usage.d.ts +26 -0
  249. package/src/lib/server/api-v1/usage.js +73 -0
  250. package/src/lib/server/api-v1/usage.js.map +1 -0
  251. package/src/lib/server/campaign-filing-activity.d.ts +1 -1
  252. package/src/lib/server/campaign-filing-activity.js +7 -11
  253. package/src/lib/server/campaign-filing-activity.js.map +1 -1
  254. package/src/lib/server/capture-contact.js +15 -4
  255. package/src/lib/server/capture-contact.js.map +1 -1
  256. package/src/lib/server/consent-group-participant.js +1 -1
  257. package/src/lib/server/consent-group-participant.js.map +1 -1
  258. package/src/lib/server/contact-email-engagement.d.ts +33 -0
  259. package/src/lib/server/contact-email-engagement.js +146 -0
  260. package/src/lib/server/contact-email-engagement.js.map +1 -0
  261. package/src/lib/server/contact-lifecycle-floor.d.ts +89 -0
  262. package/src/lib/server/contact-lifecycle-floor.js +91 -0
  263. package/src/lib/server/contact-lifecycle-floor.js.map +1 -0
  264. package/src/lib/server/contact-remove.d.ts +75 -0
  265. package/src/lib/server/contact-remove.js +244 -0
  266. package/src/lib/server/contact-remove.js.map +1 -0
  267. package/src/lib/server/contact-update.js +4 -3
  268. package/src/lib/server/contact-update.js.map +1 -1
  269. package/src/lib/server/contacts-import.d.ts +47 -11
  270. package/src/lib/server/contacts-import.js +171 -71
  271. package/src/lib/server/contacts-import.js.map +1 -1
  272. package/src/lib/server/contacts-merge.d.ts +1 -1
  273. package/src/lib/server/contacts-merge.js +3 -2
  274. package/src/lib/server/contacts-merge.js.map +1 -1
  275. package/src/lib/server/container-detach.d.ts +31 -0
  276. package/src/lib/server/container-detach.js +115 -0
  277. package/src/lib/server/container-detach.js.map +1 -0
  278. package/src/lib/server/convert-host-lead.d.ts +189 -0
  279. package/src/lib/server/convert-host-lead.js +518 -0
  280. package/src/lib/server/convert-host-lead.js.map +1 -0
  281. package/src/lib/server/crm-inbound-email.d.ts +162 -0
  282. package/src/lib/server/crm-inbound-email.js +259 -0
  283. package/src/lib/server/crm-inbound-email.js.map +1 -0
  284. package/src/lib/server/crm-records-meter.d.ts +50 -0
  285. package/src/lib/server/crm-records-meter.js +126 -0
  286. package/src/lib/server/crm-records-meter.js.map +1 -0
  287. package/src/lib/server/crm-sharing.d.ts +108 -0
  288. package/src/lib/server/crm-sharing.js +790 -0
  289. package/src/lib/server/crm-sharing.js.map +1 -0
  290. package/src/lib/server/daily-digest-route.d.ts +101 -0
  291. package/src/lib/server/daily-digest-route.js +578 -0
  292. package/src/lib/server/daily-digest-route.js.map +1 -0
  293. package/src/lib/server/export-route.d.ts +60 -0
  294. package/src/lib/server/export-route.js +447 -0
  295. package/src/lib/server/export-route.js.map +1 -0
  296. package/src/lib/server/inbound-address.js +5 -3
  297. package/src/lib/server/inbound-address.js.map +1 -1
  298. package/src/lib/server/inbound-route.d.ts +74 -0
  299. package/src/lib/server/inbound-route.js +260 -0
  300. package/src/lib/server/inbound-route.js.map +1 -0
  301. package/src/lib/server/lead-campaign-carry.js +3 -3
  302. package/src/lib/server/lead-campaign-carry.js.map +1 -1
  303. package/src/lib/server/lead-convert.d.ts +1 -1
  304. package/src/lib/server/lead-convert.js +2 -2
  305. package/src/lib/server/lead-convert.js.map +1 -1
  306. package/src/lib/server/lead-create.d.ts +1 -1
  307. package/src/lib/server/lead-create.js +10 -15
  308. package/src/lib/server/lead-create.js.map +1 -1
  309. package/src/lib/server/lead-nurturing.d.ts +23 -0
  310. package/src/lib/server/lead-nurturing.js +82 -0
  311. package/src/lib/server/lead-nurturing.js.map +1 -0
  312. package/src/lib/server/leads-import.js +7 -7
  313. package/src/lib/server/leads-import.js.map +1 -1
  314. package/src/lib/server/merge-contacts.d.ts +55 -0
  315. package/src/lib/server/merge-contacts.js +274 -0
  316. package/src/lib/server/merge-contacts.js.map +1 -0
  317. package/src/lib/server/pipeline-record-index.d.ts +36 -0
  318. package/src/lib/server/pipeline-record-index.js +87 -0
  319. package/src/lib/server/pipeline-record-index.js.map +1 -0
  320. package/src/lib/server/record-email-state.js +23 -0
  321. package/src/lib/server/record-email-state.js.map +1 -1
  322. package/src/lib/server/record-facts.js +2 -1
  323. package/src/lib/server/record-facts.js.map +1 -1
  324. package/src/lib/server/record-timeline.d.ts +12 -0
  325. package/src/lib/server/record-timeline.js +127 -15
  326. package/src/lib/server/record-timeline.js.map +1 -1
  327. package/src/lib/server/task-reminders-route.d.ts +82 -0
  328. package/src/lib/server/task-reminders-route.js +499 -0
  329. package/src/lib/server/task-reminders-route.js.map +1 -0
  330. package/src/lib/server/task-routes.d.ts +2 -0
  331. package/src/lib/server/task-routes.js +22 -3
  332. package/src/lib/server/task-routes.js.map +1 -1
  333. package/src/lib/server-deal-stage.js +2 -1
  334. package/src/lib/server-deal-stage.js.map +1 -1
  335. package/src/lib/server.d.ts +5 -2
  336. package/src/lib/server.js +47 -2
  337. package/src/lib/server.js.map +1 -1
  338. package/src/lib/testing/stand-in-site-record-routes.d.ts +26 -0
  339. package/src/lib/testing/stand-in-site-record-routes.js +55 -0
  340. package/src/lib/testing/stand-in-site-record-routes.js.map +1 -0
  341. package/src/lib/usage-axes.d.ts +35 -0
  342. package/src/lib/usage-axes.js +75 -0
  343. package/src/lib/usage-axes.js.map +1 -0
  344. package/src/lib/components/use-emails-hub-path.d.ts +0 -24
  345. package/src/lib/components/use-emails-hub-path.js +0 -59
  346. package/src/lib/components/use-emails-hub-path.js.map +0 -1
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/model/crm-import.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/**\n * BRINGING A SPREADSHEET OF PEOPLE INTO THE CRM — the pure half (AGL-2602).\n *\n * A merchant arriving from another CRM has a CSV: a header row naming\n * columns in that product's vocabulary, and one person per line. Nothing\n * about that file is in our shape. This module is every decision about the\n * file that needs no Firestore and no React — which columns mean what, how a\n * cell becomes a stored value, and what to say about a row that cannot be\n * stored — so that the console drawer and the server route read the same\n * rules rather than two copies of them.\n *\n * ## Three stages, and only the last one touches a record\n *\n * 1. {@link guessContactImportMapping} reads the header and proposes a\n * column → field mapping from a table of aliases. It is a PROPOSAL: the\n * drawer shows it and the operator corrects it, because an export's\n * \"Company\" column may hold a job title and no alias table can know.\n * 2. {@link mapContactImportRow} applies the mapping to one line, producing\n * the raw row the browser posts. Strings only — the browser trims nothing\n * and validates nothing, so the server is the one place the file is\n * judged and a stale drawer cannot judge it differently.\n * 3. {@link normalizeContactImportRow} turns a raw row into the values that\n * will be written, through the SAME normalizers every other door uses:\n * `normalizeContactEmail` for the dedupe key, `normalizePhone` for E.164,\n * `normalizeAddress` for the postal shape, `isContactLifecycleStage` for\n * the stage. An importer with its own idea of a valid phone number is how\n * one collection ends up storing a value three ways.\n *\n * ## A bad cell is dropped and named; a bad row is skipped and named\n *\n * The only thing that makes a row unstorable is an unusable address, because\n * the address is the identity the shared row is keyed by. Everything else is\n * a value on the row: a phone that cannot be read as a number is left off\n * the contact and REPORTED under {@link ContactImportRow.dropped}, so the\n * operator learns that forty rows had a phone in a format we could not read\n * rather than discovering, weeks later, that the phone column silently went\n * nowhere. `list-import.ts` applies the same rule to a line that is not an\n * address: reported, never quietly discarded.\n *\n * ## Ceilings are on the work, never on the audience\n *\n * {@link CONTACT_IMPORT_MAX_ROWS} bounds one FILE, and the drawer says so\n * before anything is sent. {@link CONTACT_IMPORT_CHUNK_SIZE} bounds one\n * REQUEST, so a five-thousand-row file is twenty-five requests with a\n * progress bar between them rather than one that times out halfway with no\n * record of what it did. Neither one ever trims a person to fit: a row past\n * the file ceiling is refused with the file, and a row is never dropped to\n * make a chunk smaller. The audience band is enforced where every capture\n * enforces it — inside `upsertHostContact` — and an import reports that\n * refusal per row rather than pre-empting it with a count of its own.\n */\n\nimport type { AglynPostalAddress } from '@aglyn/aglyn/foundation/definitions/contact.types'\nimport { normalizeAddress, normalizePhone } from '@aglyn/aglyn/foundation/definitions/contact.types'\nimport { normalizeContactEmail } from '@aglyn/aglyn/app-utils/contacts'\nimport {\n CSV_IMPORT_CHUNK_SIZE,\n CSV_IMPORT_MAX_BODY_BYTES,\n CSV_IMPORT_MAX_ROWS,\n CSV_IMPORT_PREVIEW_ROWS,\n customImportTarget,\n customImportTargetKey,\n emptyImportResult,\n guessImportMapping,\n importAliasKeys,\n type ImportChunkResult,\n type ImportDroppedValue,\n importSkippedCsv,\n importTextValue,\n mapImportRow,\n mergeImportResults,\n parseImportFlag,\n parseImportTags,\n} from '@aglyn/aglyn/app-utils/csv-import'\nimport {\n CONTACT_LIFECYCLE_STAGE_LABELS,\n CONTACT_LIFECYCLE_STAGES,\n type ContactCustomValue,\n type ContactFieldDefinition,\n type ContactLifecycleStage,\n isContactLifecycleStage,\n} from '@aglyn/aglyn/app-utils/crm'\n\n/*\n * The custom-target grammar is the shared module's; it is re-exported here\n * because every reader of a contact mapping has always taken it from this\n * file, and the companies import reads the same two functions from theirs.\n */\nexport { customImportTarget, customImportTargetKey } from '@aglyn/aglyn/app-utils/csv-import'\n\n/**\n * The ceilings are the shared ones (`csv-import.ts`), under the names this\n * file has always exported: one file, one request and one preview are\n * bounded the same way for every collection, because the bounds are about\n * the browser's memory and a request's budget rather than about people.\n */\nexport const CONTACT_IMPORT_MAX_ROWS = CSV_IMPORT_MAX_ROWS\n\nexport const CONTACT_IMPORT_CHUNK_SIZE = CSV_IMPORT_CHUNK_SIZE\n\nexport const CONTACT_IMPORT_MAX_BODY_BYTES = CSV_IMPORT_MAX_BODY_BYTES\n\nexport const CONTACT_IMPORT_PREVIEW_ROWS = CSV_IMPORT_PREVIEW_ROWS\n\n/**\n * The most tags one row may carry — the profile drawer's own cap, so an\n * imported person cannot hold more tags than a hand-edited one.\n */\nexport const CONTACT_IMPORT_TAGS_MAX = 20\n\n/** The longest text a name, title, company name or custom text value keeps. */\nconst NAME_MAX = 120\nconst CUSTOM_TEXT_MAX = 1_000\n\n/**\n * The standard fields a column may be mapped to, in the order the mapping\n * menu lists them. A custom field is mapped by its key — see\n * {@link ContactImportTargetId}.\n */\nexport const CONTACT_IMPORT_FIELDS = [\n 'email',\n 'name',\n 'phone',\n 'jobTitle',\n 'companyName',\n 'addressLine1',\n 'addressLine2',\n 'addressCity',\n 'addressState',\n 'addressPostalCode',\n 'addressCountry',\n 'tags',\n 'ownerEmail',\n 'lifecycleStage',\n 'marketingConsent',\n] as const\n\nexport type ContactImportField = (typeof CONTACT_IMPORT_FIELDS)[number]\n\n/** How each field reads in the mapping menu. Typed so a field cannot ship unlabeled. */\nexport const CONTACT_IMPORT_FIELD_LABELS: Record<ContactImportField, string> = {\n email: 'Email (required)',\n name: 'Name',\n phone: 'Phone',\n jobTitle: 'Job title',\n companyName: 'Company name',\n addressLine1: 'Address line 1',\n addressLine2: 'Address line 2',\n addressCity: 'City',\n addressState: 'State or region',\n addressPostalCode: 'Postal code',\n addressCountry: 'Country (two-letter code)',\n tags: 'Tags (comma or | separated)',\n ownerEmail: 'Owner (team member email)',\n lifecycleStage: 'Lifecycle stage',\n marketingConsent: 'Marketing consent (yes/no)',\n}\n\n/**\n * Header aliases per field, matched after `importHeaderKey` normalization.\n *\n * The vocabulary of the exports people actually arrive with — HubSpot's\n * \"First Name\"/\"Last Name\" pair is deliberately NOT here, because two columns\n * cannot map to one field and a guess that took only the first name would\n * store half a name and look right in the preview. An operator with a split\n * name maps one of the two by hand, and the preview shows what they chose.\n */\nconst FIELD_ALIASES: Record<ContactImportField, readonly string[]> = {\n email: [\n 'email',\n 'email address',\n 'e-mail',\n 'e-mail address',\n 'contact email',\n 'primary email',\n 'work email',\n ],\n name: ['name', 'full name', 'contact name', 'display name', 'person'],\n phone: [\n 'phone',\n 'phone number',\n 'telephone',\n 'mobile',\n 'mobile phone',\n 'cell',\n 'work phone',\n ],\n jobTitle: ['job title', 'title', 'position', 'role'],\n companyName: ['company', 'company name', 'organization', 'organisation', 'account'],\n addressLine1: ['address', 'address line 1', 'street', 'street address', 'address 1'],\n addressLine2: ['address line 2', 'address 2', 'street 2', 'apartment', 'suite'],\n addressCity: ['city', 'town', 'locality'],\n addressState: ['state', 'region', 'province', 'county', 'state/region'],\n addressPostalCode: ['postal code', 'postcode', 'zip', 'zip code', 'post code'],\n addressCountry: ['country', 'country code', 'country/region'],\n tags: ['tags', 'tag', 'labels', 'groups', 'segments'],\n ownerEmail: ['owner', 'owner email', 'contact owner', 'assigned to', 'account owner'],\n lifecycleStage: ['lifecycle stage', 'stage', 'lifecycle', 'status', 'lead status'],\n marketingConsent: [\n 'marketing consent',\n 'consent',\n 'opt in',\n 'opt-in',\n 'opted in',\n 'subscribed',\n 'marketing opt in',\n 'email consent',\n ],\n}\n\n/**\n * What a column is mapped to: a standard field by name, or a custom field\n * as `custom:<key>` — see `customImportTarget`.\n */\nexport type ContactImportTargetId = ContactImportField | `custom:${string}`\n\n/** Column index → target. A column absent from the map is not imported. */\nexport type ContactImportMapping = Record<number, ContactImportTargetId>\n\n/** {@link FIELD_ALIASES}, normalized once for the matcher. */\nconst FIELD_ALIAS_KEYS = importAliasKeys(CONTACT_IMPORT_FIELDS, FIELD_ALIASES)\n\n/**\n * A proposed mapping from a file's header row — the shared matcher over the\n * contact vocabulary. Custom fields win over standard aliases when a header\n * matches a definition's label or key exactly: a merchant who defined a\n * field called \"Status\" meant THEIR status, not our lifecycle stage.\n */\nexport function guessContactImportMapping(\n columns: readonly string[],\n fields: readonly Pick<ContactFieldDefinition, 'key' | 'label'>[] = [],\n): ContactImportMapping {\n return guessImportMapping(columns, CONTACT_IMPORT_FIELDS, FIELD_ALIAS_KEYS, fields)\n}\n\n/**\n * What the browser posts for one line: the cells the mapping selected,\n * under the field they were mapped to, verbatim.\n *\n * Values are `unknown` because the server reads this off an untrusted body;\n * the mapper below only ever writes strings into it.\n */\nexport interface ContactImportRawRow {\n email?: unknown\n name?: unknown\n phone?: unknown\n jobTitle?: unknown\n companyName?: unknown\n addressLine1?: unknown\n addressLine2?: unknown\n addressCity?: unknown\n addressState?: unknown\n addressPostalCode?: unknown\n addressCountry?: unknown\n tags?: unknown\n ownerEmail?: unknown\n lifecycleStage?: unknown\n marketingConsent?: unknown\n /** Custom values keyed by definition key. */\n custom?: unknown\n}\n\n/** One parsed line under the mapping. Empty cells are left absent. */\nexport function mapContactImportRow(\n cells: readonly string[],\n mapping: ContactImportMapping,\n): ContactImportRawRow {\n return mapImportRow(cells, mapping)\n}\n\n/** Why one row was not stored. */\nexport type ContactImportSkipReason =\n | 'invalid-email'\n | 'duplicate'\n | 'audience-band'\n /** The person was erased from this workspace at their request (AGL-2623). */\n | 'erased'\n | 'write-failed'\n\n/** How a skip reason reads on screen and in the downloaded file. */\nexport const CONTACT_IMPORT_SKIP_LABELS: Record<ContactImportSkipReason, string> = {\n 'invalid-email': 'Not a valid email address',\n duplicate: 'Appears earlier in the file',\n 'audience-band': 'Contact limit reached',\n erased: 'Erased from this workspace at their request',\n 'write-failed': 'Could not be saved',\n}\n\n/** A cell the file carried that could not be read as the field it was mapped to. */\nexport type ContactImportDroppedValue = ImportDroppedValue\n\n/** One row, ready to be written. */\nexport interface ContactImportRow {\n email: string\n name?: string\n /** E.164. */\n phone?: string\n jobTitle?: string\n companyName?: string\n address?: AglynPostalAddress\n /** Lowercased, deduplicated, capped at {@link CONTACT_IMPORT_TAGS_MAX}. */\n tags: string[]\n /** Normalized, for the server to resolve against the org's members. */\n ownerEmail?: string\n lifecycleStage?: ContactLifecycleStage\n marketingConsent: boolean\n /** Only the keys whose definition the caller supplied and whose value read. */\n custom: Record<string, ContactCustomValue>\n dropped: ContactImportDroppedValue[]\n}\n\nexport type ContactImportRowVerdict =\n | { ok: true; row: ContactImportRow }\n | { ok: false; reason: 'invalid-email'; input: string }\n\n/**\n * A tag cell as the tags it names.\n *\n * `|` or `,` separated — the CSV export writes `|`, most other products\n * write `,` — lowercased and trimmed like the profile drawer's own tag\n * field, so an imported `VIP` and a typed `vip` are one tag and not two.\n */\nexport function parseContactImportTags(value: unknown): string[] {\n return parseImportTags(value, CONTACT_IMPORT_TAGS_MAX)\n}\n\n/**\n * A yes/no cell as a boolean, or `null` when it is neither.\n *\n * The affirmatives are the ones consent and checkbox columns actually\n * carry; the negatives are listed so that an explicit `no` is a `false`\n * rather than an unreadable value that gets reported as dropped.\n */\nexport function parseContactImportFlag(value: unknown): boolean | null {\n return parseImportFlag(value)\n}\n\n/**\n * A stage cell as a stage, accepting the stored id (`sales-qualified`), its\n * label (\"Sales qualified\") and the spellings between them\n * (`Sales_Qualified`), or `null` when it names none of them.\n */\nexport function parseContactImportLifecycleStage(\n value: unknown,\n): ContactLifecycleStage | null {\n const text = String(value ?? '')\n .trim()\n .toLowerCase()\n if (!text) return null\n const key = text.replace(/[\\s_]+/g, '-')\n if (isContactLifecycleStage(key)) return key\n for (const stage of CONTACT_LIFECYCLE_STAGES) {\n if (CONTACT_LIFECYCLE_STAGE_LABELS[stage].toLowerCase() === text) return stage\n }\n return null\n}\n\n/**\n * A cell as the value a custom field of this type stores.\n *\n * `{ value }` when it read, `{ value: undefined }` for a blank cell (nothing\n * to write and nothing to report), and `null` when the cell held something\n * the type cannot hold — a word in a number field, a choice a select does\n * not offer. A date is stored as epoch milliseconds, the shape every other\n * timestamp on a CRM record takes (`dueAtMs`, `expectedCloseAtMs`).\n */\nexport function parseContactImportCustomValue(\n definition: Pick<ContactFieldDefinition, 'type' | 'options'>,\n value: unknown,\n): { value: ContactCustomValue | undefined } | null {\n const text = String(value ?? '').trim()\n if (!text) return { value: undefined }\n switch (definition.type) {\n case 'text':\n case 'url':\n return { value: text.slice(0, CUSTOM_TEXT_MAX) }\n case 'number': {\n const number = Number(text.replace(/[,\\s$€£]/g, ''))\n return Number.isFinite(number) ? { value: number } : null\n }\n case 'date': {\n const ms = Date.parse(text)\n return Number.isFinite(ms) ? { value: ms } : null\n }\n case 'select': {\n const wanted = text.toLowerCase()\n const option = (definition.options ?? []).find(\n (candidate) => String(candidate).trim().toLowerCase() === wanted,\n )\n return option === undefined ? null : { value: String(option) }\n }\n case 'checkbox': {\n const flag = parseContactImportFlag(text)\n return flag === null ? null : { value: flag }\n }\n default:\n return null\n }\n}\n\n/**\n * One raw row as the values that will be written, or the reason it cannot\n * be.\n *\n * `fields` is the holder's live definitions: a custom value under a key\n * with no definition is dropped and reported, because a value nobody\n * defined a field for is a value no form will ever show.\n */\nexport function normalizeContactImportRow(\n raw: ContactImportRawRow,\n fields: readonly Pick<ContactFieldDefinition, 'key' | 'type' | 'options'>[] = [],\n): ContactImportRowVerdict {\n const email = normalizeContactEmail(raw.email)\n if (!email) {\n return { ok: false, reason: 'invalid-email', input: String(raw.email ?? '').trim() }\n }\n const dropped: ContactImportDroppedValue[] = []\n const drop = (field: string, value: unknown) => {\n dropped.push({ field, value: String(value ?? '').trim() })\n }\n\n const row: ContactImportRow = {\n email,\n tags: parseContactImportTags(raw.tags),\n marketingConsent: false,\n custom: {},\n dropped,\n }\n\n const name = importTextValue(raw.name, NAME_MAX)\n if (name) row.name = name\n const jobTitle = importTextValue(raw.jobTitle, NAME_MAX)\n if (jobTitle) row.jobTitle = jobTitle\n const companyName = importTextValue(raw.companyName, NAME_MAX)\n if (companyName) row.companyName = companyName\n\n const phoneText = importTextValue(raw.phone, 64)\n if (phoneText) {\n const phone = normalizePhone(phoneText)\n if (phone) row.phone = phone\n else drop('phone', phoneText)\n }\n\n const address = normalizeAddress({\n line1: importTextValue(raw.addressLine1, 200),\n line2: importTextValue(raw.addressLine2, 200),\n city: importTextValue(raw.addressCity, 120),\n state: importTextValue(raw.addressState, 120),\n postalCode: importTextValue(raw.addressPostalCode, 32),\n country: importTextValue(raw.addressCountry, 8),\n })\n if (address) row.address = address\n // The country is the one address part the normalizer drops silently — a\n // typed name is not a code — so it is the one part the report has to name.\n const countryText = importTextValue(raw.addressCountry, 64)\n if (countryText && !address?.country) drop('addressCountry', countryText)\n\n const ownerText = importTextValue(raw.ownerEmail, 320)\n if (ownerText) {\n const owner = normalizeContactEmail(ownerText)\n if (owner) row.ownerEmail = owner\n else drop('ownerEmail', ownerText)\n }\n\n const stageText = importTextValue(raw.lifecycleStage, 64)\n if (stageText) {\n const stage = parseContactImportLifecycleStage(stageText)\n if (stage) row.lifecycleStage = stage\n else drop('lifecycleStage', stageText)\n }\n\n const consentText = raw.marketingConsent\n if (consentText !== undefined && consentText !== null && String(consentText).trim()) {\n const flag = parseContactImportFlag(consentText)\n if (flag === null) drop('marketingConsent', consentText)\n else row.marketingConsent = flag\n }\n\n const custom =\n raw.custom && typeof raw.custom === 'object' && !Array.isArray(raw.custom)\n ? (raw.custom as Record<string, unknown>)\n : {}\n const definitions = new Map(fields.map((field) => [field.key, field]))\n for (const [key, value] of Object.entries(custom)) {\n const definition = definitions.get(key)\n if (!definition) {\n drop(customImportTarget(key), value)\n continue\n }\n const parsed = parseContactImportCustomValue(definition, value)\n if (!parsed) drop(customImportTarget(key), value)\n else if (parsed.value !== undefined) row.custom[key] = parsed.value\n }\n\n return { ok: true, row }\n}\n\n/** One row the server did not store, by its index in the request, named by address. */\nexport interface ContactImportSkippedRow {\n index: number\n email: string\n reason: ContactImportSkipReason\n}\n\n/**\n * What one request did — the shared tally plus how many companies the\n * company column brought into being. The drawer sums these across a file.\n */\nexport interface ContactImportChunkResult\n extends ImportChunkResult<ContactImportSkippedRow> {\n companiesCreated: number\n}\n\n/** An empty tally, for the drawer to fold chunk results into. */\nexport function emptyContactImportResult(): ContactImportChunkResult {\n return { ...emptyImportResult<ContactImportSkippedRow>(), companiesCreated: 0 }\n}\n\n/**\n * Two results as one — the shared fold, with the company tally beside it.\n * `offset` is where the chunk started in the file, so a skipped row's index\n * comes back as its position in the FILE rather than in the request.\n */\nexport function mergeContactImportResults(\n total: ContactImportChunkResult,\n chunk: ContactImportChunkResult,\n offset = 0,\n): ContactImportChunkResult {\n return {\n ...mergeImportResults(total, chunk, offset),\n companiesCreated: total.companiesCreated + Number(chunk.companiesCreated ?? 0),\n }\n}\n\n/**\n * The skipped rows as a file the operator can fix and re-import: the\n * original columns verbatim plus a trailing `Skipped because` column.\n */\nexport function contactImportSkippedCsv(\n columns: readonly string[],\n entries: readonly { cells: readonly string[]; reason: ContactImportSkipReason }[],\n): string {\n return importSkippedCsv(columns, entries, CONTACT_IMPORT_SKIP_LABELS)\n}\n"],"names":["normalizeAddress","normalizePhone","normalizeContactEmail","CSV_IMPORT_CHUNK_SIZE","CSV_IMPORT_MAX_BODY_BYTES","CSV_IMPORT_MAX_ROWS","CSV_IMPORT_PREVIEW_ROWS","customImportTarget","emptyImportResult","guessImportMapping","importAliasKeys","importSkippedCsv","importTextValue","mapImportRow","mergeImportResults","parseImportFlag","parseImportTags","CONTACT_LIFECYCLE_STAGE_LABELS","CONTACT_LIFECYCLE_STAGES","isContactLifecycleStage","customImportTargetKey","CONTACT_IMPORT_MAX_ROWS","CONTACT_IMPORT_CHUNK_SIZE","CONTACT_IMPORT_MAX_BODY_BYTES","CONTACT_IMPORT_PREVIEW_ROWS","CONTACT_IMPORT_TAGS_MAX","NAME_MAX","CUSTOM_TEXT_MAX","CONTACT_IMPORT_FIELDS","CONTACT_IMPORT_FIELD_LABELS","email","name","phone","jobTitle","companyName","addressLine1","addressLine2","addressCity","addressState","addressPostalCode","addressCountry","tags","ownerEmail","lifecycleStage","marketingConsent","FIELD_ALIASES","FIELD_ALIAS_KEYS","guessContactImportMapping","columns","fields","mapContactImportRow","cells","mapping","CONTACT_IMPORT_SKIP_LABELS","duplicate","erased","parseContactImportTags","value","parseContactImportFlag","parseContactImportLifecycleStage","text","String","trim","toLowerCase","key","replace","stage","parseContactImportCustomValue","definition","undefined","type","slice","number","Number","isFinite","ms","Date","parse","wanted","option","options","find","candidate","flag","normalizeContactImportRow","raw","ok","reason","input","dropped","drop","field","push","row","custom","phoneText","address","line1","line2","city","state","postalCode","country","countryText","ownerText","owner","stageText","consentText","Array","isArray","definitions","Map","map","Object","entries","get","parsed","emptyContactImportResult","companiesCreated","mergeContactImportResults","total","chunk","offset","contactImportSkippedCsv"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/model/crm-import.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/**\n * BRINGING A SPREADSHEET OF PEOPLE INTO THE CRM — the pure half (AGL-2602).\n *\n * A merchant arriving from another CRM has a CSV: a header row naming\n * columns in that product's vocabulary, and one person per line. Nothing\n * about that file is in our shape. This module is every decision about the\n * file that needs no Firestore and no React — which columns mean what, how a\n * cell becomes a stored value, and what to say about a row that cannot be\n * stored — so that the console drawer and the server route read the same\n * rules rather than two copies of them.\n *\n * ## Three stages, and only the last one touches a record\n *\n * 1. {@link guessContactImportMapping} reads the header and proposes a\n * column → field mapping from a table of aliases. It is a PROPOSAL: the\n * drawer shows it and the operator corrects it, because an export's\n * \"Company\" column may hold a job title and no alias table can know.\n * 2. {@link mapContactImportRow} applies the mapping to one line, producing\n * the raw row the browser posts. Strings only — the browser trims nothing\n * and validates nothing, so the server is the one place the file is\n * judged and a stale drawer cannot judge it differently.\n * 3. {@link normalizeContactImportRow} turns a raw row into the values that\n * will be written, through the SAME normalizers every other door uses:\n * `normalizeContactEmail` for the dedupe key, `normalizePhone` for E.164,\n * `normalizeAddress` for the postal shape, `isContactLifecycleStage` for\n * the stage. An importer with its own idea of a valid phone number is how\n * one collection ends up storing a value three ways.\n *\n * ## A bad cell is dropped and named; a bad row is skipped and named\n *\n * The only thing that makes a row unstorable is an unusable address, because\n * the address is the identity the shared row is keyed by. Everything else is\n * a value on the row: a phone that cannot be read as a number is left off\n * the contact and REPORTED under {@link ContactImportRow.dropped}, so the\n * operator learns that forty rows had a phone in a format we could not read\n * rather than discovering, weeks later, that the phone column silently went\n * nowhere. `list-import.ts` applies the same rule to a line that is not an\n * address: reported, never quietly discarded.\n *\n * ## Ceilings are on the work, never on the audience\n *\n * {@link CONTACT_IMPORT_MAX_ROWS} bounds one FILE, and the drawer says so\n * before anything is sent. {@link CONTACT_IMPORT_CHUNK_SIZE} bounds one\n * REQUEST, so a five-thousand-row file is twenty-five requests with a\n * progress bar between them rather than one that times out halfway with no\n * record of what it did. Neither one ever trims a person to fit: a row past\n * the file ceiling is refused with the file, and a row is never dropped to\n * make a chunk smaller. The audience band is enforced where every capture\n * enforces it — inside `upsertHostContact` — and an import reports that\n * refusal per row rather than pre-empting it with a count of its own.\n */\n\nimport type { AglynPostalAddress } from '@aglyn/aglyn/foundation/definitions/contact.types'\nimport { normalizeAddress, normalizePhone } from '@aglyn/aglyn/foundation/definitions/contact.types'\nimport { normalizeContactEmail } from '@aglyn/aglyn/app-utils/contacts'\nimport {\n CSV_IMPORT_CHUNK_SIZE,\n CSV_IMPORT_MAX_BODY_BYTES,\n CSV_IMPORT_MAX_ROWS,\n CSV_IMPORT_PREVIEW_ROWS,\n customImportTarget,\n customImportTargetKey,\n emptyImportResult,\n guessImportMapping,\n importAliasKeys,\n type ImportChunkResult,\n type ImportDroppedValue,\n importSkippedCsv,\n importTextValue,\n mapImportRow,\n mergeImportResults,\n parseImportFlag,\n parseImportTags,\n} from '@aglyn/aglyn/app-utils/csv-import'\nimport {\n CONTACT_LIFECYCLE_STAGE_LABELS,\n CONTACT_LIFECYCLE_STAGES,\n type ContactCustomValue,\n type ContactFieldDefinition,\n type ContactLifecycleStage,\n isContactLifecycleStage,\n} from '@aglyn/aglyn/app-utils/crm'\n\n/*\n * The custom-target grammar is the shared module's; it is re-exported here\n * because every reader of a contact mapping has always taken it from this\n * file, and the companies import reads the same two functions from theirs.\n */\nexport { customImportTarget, customImportTargetKey } from '@aglyn/aglyn/app-utils/csv-import'\n\n/**\n * The ceilings are the shared ones (`csv-import.ts`), under the names this\n * file has always exported: one file, one request and one preview are\n * bounded the same way for every collection, because the bounds are about\n * the browser's memory and a request's budget rather than about people.\n */\nexport const CONTACT_IMPORT_MAX_ROWS = CSV_IMPORT_MAX_ROWS\n\nexport const CONTACT_IMPORT_CHUNK_SIZE = CSV_IMPORT_CHUNK_SIZE\n\nexport const CONTACT_IMPORT_MAX_BODY_BYTES = CSV_IMPORT_MAX_BODY_BYTES\n\nexport const CONTACT_IMPORT_PREVIEW_ROWS = CSV_IMPORT_PREVIEW_ROWS\n\n/**\n * The most tags one row may carry — the profile drawer's own cap, so an\n * imported person cannot hold more tags than a hand-edited one.\n */\nexport const CONTACT_IMPORT_TAGS_MAX = 20\n\n/** The longest text a name, title, company name or custom text value keeps. */\nconst NAME_MAX = 120\nconst CUSTOM_TEXT_MAX = 1_000\n\n/**\n * The standard fields a column may be mapped to, in the order the mapping\n * menu lists them. A custom field is mapped by its key — see\n * {@link ContactImportTargetId}.\n */\nexport const CONTACT_IMPORT_FIELDS = [\n 'email',\n 'name',\n 'phone',\n 'jobTitle',\n 'companyName',\n 'addressLine1',\n 'addressLine2',\n 'addressCity',\n 'addressState',\n 'addressPostalCode',\n 'addressCountry',\n 'tags',\n 'ownerEmail',\n 'lifecycleStage',\n 'marketingConsent',\n] as const\n\nexport type ContactImportField = (typeof CONTACT_IMPORT_FIELDS)[number]\n\n/** How each field reads in the mapping menu. Typed so a field cannot ship unlabeled. */\nexport const CONTACT_IMPORT_FIELD_LABELS: Record<ContactImportField, string> = {\n email: 'Email (required)',\n name: 'Name',\n phone: 'Phone',\n jobTitle: 'Job title',\n companyName: 'Company name',\n addressLine1: 'Address line 1',\n addressLine2: 'Address line 2',\n addressCity: 'City',\n addressState: 'State or region',\n addressPostalCode: 'Postal code',\n addressCountry: 'Country (two-letter code)',\n tags: 'Tags (comma or | separated)',\n ownerEmail: 'Owner (team member email)',\n lifecycleStage: 'Lifecycle stage',\n marketingConsent: 'Marketing consent (yes/no)',\n}\n\n/**\n * Header aliases per field, matched after `importHeaderKey` normalization.\n *\n * The vocabulary of the exports people actually arrive with — HubSpot's\n * \"First Name\"/\"Last Name\" pair is deliberately NOT here, because two columns\n * cannot map to one field and a guess that took only the first name would\n * store half a name and look right in the preview. An operator with a split\n * name maps one of the two by hand, and the preview shows what they chose.\n */\nconst FIELD_ALIASES: Record<ContactImportField, readonly string[]> = {\n email: [\n 'email',\n 'email address',\n 'e-mail',\n 'e-mail address',\n 'contact email',\n 'primary email',\n 'work email',\n ],\n name: ['name', 'full name', 'contact name', 'display name', 'person'],\n phone: [\n 'phone',\n 'phone number',\n 'telephone',\n 'mobile',\n 'mobile phone',\n 'cell',\n 'work phone',\n ],\n jobTitle: ['job title', 'title', 'position', 'role'],\n companyName: ['company', 'company name', 'organization', 'organisation', 'account'],\n addressLine1: ['address', 'address line 1', 'street', 'street address', 'address 1'],\n addressLine2: ['address line 2', 'address 2', 'street 2', 'apartment', 'suite'],\n addressCity: ['city', 'town', 'locality'],\n addressState: ['state', 'region', 'province', 'county', 'state/region'],\n addressPostalCode: ['postal code', 'postcode', 'zip', 'zip code', 'post code'],\n addressCountry: ['country', 'country code', 'country/region'],\n tags: ['tags', 'tag', 'labels', 'groups', 'segments'],\n ownerEmail: ['owner', 'owner email', 'contact owner', 'assigned to', 'account owner'],\n lifecycleStage: ['lifecycle stage', 'stage', 'lifecycle', 'status', 'lead status'],\n marketingConsent: [\n 'marketing consent',\n 'consent',\n 'opt in',\n 'opt-in',\n 'opted in',\n 'subscribed',\n 'marketing opt in',\n 'email consent',\n ],\n}\n\n/**\n * What a column is mapped to: a standard field by name, or a custom field\n * as `custom:<key>` — see `customImportTarget`.\n */\nexport type ContactImportTargetId = ContactImportField | `custom:${string}`\n\n/** Column index → target. A column absent from the map is not imported. */\nexport type ContactImportMapping = Record<number, ContactImportTargetId>\n\n/** {@link FIELD_ALIASES}, normalized once for the matcher. */\nconst FIELD_ALIAS_KEYS = importAliasKeys(CONTACT_IMPORT_FIELDS, FIELD_ALIASES)\n\n/**\n * A proposed mapping from a file's header row — the shared matcher over the\n * contact vocabulary. Custom fields win over standard aliases when a header\n * matches a definition's label or key exactly: a merchant who defined a\n * field called \"Status\" meant THEIR status, not our lifecycle stage.\n */\nexport function guessContactImportMapping(\n columns: readonly string[],\n fields: readonly Pick<ContactFieldDefinition, 'key' | 'label'>[] = [],\n): ContactImportMapping {\n return guessImportMapping(columns, CONTACT_IMPORT_FIELDS, FIELD_ALIAS_KEYS, fields)\n}\n\n/**\n * What the browser posts for one line: the cells the mapping selected,\n * under the field they were mapped to, verbatim.\n *\n * Values are `unknown` because the server reads this off an untrusted body;\n * the mapper below only ever writes strings into it.\n */\nexport interface ContactImportRawRow {\n email?: unknown\n name?: unknown\n phone?: unknown\n jobTitle?: unknown\n companyName?: unknown\n addressLine1?: unknown\n addressLine2?: unknown\n addressCity?: unknown\n addressState?: unknown\n addressPostalCode?: unknown\n addressCountry?: unknown\n tags?: unknown\n ownerEmail?: unknown\n lifecycleStage?: unknown\n marketingConsent?: unknown\n /** Custom values keyed by definition key. */\n custom?: unknown\n}\n\n/** One parsed line under the mapping. Empty cells are left absent. */\nexport function mapContactImportRow(\n cells: readonly string[],\n mapping: ContactImportMapping,\n): ContactImportRawRow {\n return mapImportRow(cells, mapping)\n}\n\n/** Why one row was not stored. */\nexport type ContactImportSkipReason =\n | 'invalid-email'\n | 'duplicate'\n | 'audience-band'\n /** The person was erased from this workspace at their request (AGL-2623). */\n | 'erased'\n | 'write-failed'\n /**\n * The request ran out of time before it reached the row (AGL-3423). Nothing\n * was written for it, and importing the skipped file again is safe: a row\n * that did land merges into its record rather than creating a second one.\n */\n | 'not-reached'\n\n/** How a skip reason reads on screen and in the downloaded file. */\nexport const CONTACT_IMPORT_SKIP_LABELS: Record<ContactImportSkipReason, string> = {\n 'invalid-email': 'Not a valid email address',\n duplicate: 'Appears earlier in the file',\n 'audience-band': 'Contact limit reached',\n erased: 'Erased from this workspace at their request',\n 'write-failed': 'Could not be saved',\n 'not-reached': 'Not reached in time; import the skipped rows again',\n}\n\n/** A cell the file carried that could not be read as the field it was mapped to. */\nexport type ContactImportDroppedValue = ImportDroppedValue\n\n/** One row, ready to be written. */\nexport interface ContactImportRow {\n email: string\n name?: string\n /** E.164. */\n phone?: string\n jobTitle?: string\n companyName?: string\n address?: AglynPostalAddress\n /** Lowercased, deduplicated, capped at {@link CONTACT_IMPORT_TAGS_MAX}. */\n tags: string[]\n /** Normalized, for the server to resolve against the org's members. */\n ownerEmail?: string\n lifecycleStage?: ContactLifecycleStage\n marketingConsent: boolean\n /** Only the keys whose definition the caller supplied and whose value read. */\n custom: Record<string, ContactCustomValue>\n dropped: ContactImportDroppedValue[]\n}\n\nexport type ContactImportRowVerdict =\n | { ok: true; row: ContactImportRow }\n | { ok: false; reason: 'invalid-email'; input: string }\n\n/**\n * A tag cell as the tags it names.\n *\n * `|` or `,` separated — the CSV export writes `|`, most other products\n * write `,` — lowercased and trimmed like the profile drawer's own tag\n * field, so an imported `VIP` and a typed `vip` are one tag and not two.\n */\nexport function parseContactImportTags(value: unknown): string[] {\n return parseImportTags(value, CONTACT_IMPORT_TAGS_MAX)\n}\n\n/**\n * A yes/no cell as a boolean, or `null` when it is neither.\n *\n * The affirmatives are the ones consent and checkbox columns actually\n * carry; the negatives are listed so that an explicit `no` is a `false`\n * rather than an unreadable value that gets reported as dropped.\n */\nexport function parseContactImportFlag(value: unknown): boolean | null {\n return parseImportFlag(value)\n}\n\n/**\n * A stage cell as a stage, accepting the stored id (`sales-qualified`), its\n * label (\"Sales qualified\") and the spellings between them\n * (`Sales_Qualified`), or `null` when it names none of them.\n */\nexport function parseContactImportLifecycleStage(\n value: unknown,\n): ContactLifecycleStage | null {\n const text = String(value ?? '')\n .trim()\n .toLowerCase()\n if (!text) return null\n const key = text.replace(/[\\s_]+/g, '-')\n if (isContactLifecycleStage(key)) return key\n for (const stage of CONTACT_LIFECYCLE_STAGES) {\n if (CONTACT_LIFECYCLE_STAGE_LABELS[stage].toLowerCase() === text) return stage\n }\n return null\n}\n\n/**\n * A cell as the value a custom field of this type stores.\n *\n * `{ value }` when it read, `{ value: undefined }` for a blank cell (nothing\n * to write and nothing to report), and `null` when the cell held something\n * the type cannot hold — a word in a number field, a choice a select does\n * not offer. A date is stored as epoch milliseconds, the shape every other\n * timestamp on a CRM record takes (`dueAtMs`, `expectedCloseAtMs`).\n */\nexport function parseContactImportCustomValue(\n definition: Pick<ContactFieldDefinition, 'type' | 'options'>,\n value: unknown,\n): { value: ContactCustomValue | undefined } | null {\n const text = String(value ?? '').trim()\n if (!text) return { value: undefined }\n switch (definition.type) {\n case 'text':\n case 'url':\n return { value: text.slice(0, CUSTOM_TEXT_MAX) }\n case 'number': {\n const number = Number(text.replace(/[,\\s$€£]/g, ''))\n return Number.isFinite(number) ? { value: number } : null\n }\n case 'date': {\n const ms = Date.parse(text)\n return Number.isFinite(ms) ? { value: ms } : null\n }\n case 'select': {\n const wanted = text.toLowerCase()\n const option = (definition.options ?? []).find(\n (candidate) => String(candidate).trim().toLowerCase() === wanted,\n )\n return option === undefined ? null : { value: String(option) }\n }\n case 'checkbox': {\n const flag = parseContactImportFlag(text)\n return flag === null ? null : { value: flag }\n }\n default:\n return null\n }\n}\n\n/**\n * One raw row as the values that will be written, or the reason it cannot\n * be.\n *\n * `fields` is the holder's live definitions: a custom value under a key\n * with no definition is dropped and reported, because a value nobody\n * defined a field for is a value no form will ever show.\n */\nexport function normalizeContactImportRow(\n raw: ContactImportRawRow,\n fields: readonly Pick<ContactFieldDefinition, 'key' | 'type' | 'options'>[] = [],\n): ContactImportRowVerdict {\n const email = normalizeContactEmail(raw.email)\n if (!email) {\n return { ok: false, reason: 'invalid-email', input: String(raw.email ?? '').trim() }\n }\n const dropped: ContactImportDroppedValue[] = []\n const drop = (field: string, value: unknown) => {\n dropped.push({ field, value: String(value ?? '').trim() })\n }\n\n const row: ContactImportRow = {\n email,\n tags: parseContactImportTags(raw.tags),\n marketingConsent: false,\n custom: {},\n dropped,\n }\n\n const name = importTextValue(raw.name, NAME_MAX)\n if (name) row.name = name\n const jobTitle = importTextValue(raw.jobTitle, NAME_MAX)\n if (jobTitle) row.jobTitle = jobTitle\n const companyName = importTextValue(raw.companyName, NAME_MAX)\n if (companyName) row.companyName = companyName\n\n const phoneText = importTextValue(raw.phone, 64)\n if (phoneText) {\n const phone = normalizePhone(phoneText)\n if (phone) row.phone = phone\n else drop('phone', phoneText)\n }\n\n const address = normalizeAddress({\n line1: importTextValue(raw.addressLine1, 200),\n line2: importTextValue(raw.addressLine2, 200),\n city: importTextValue(raw.addressCity, 120),\n state: importTextValue(raw.addressState, 120),\n postalCode: importTextValue(raw.addressPostalCode, 32),\n country: importTextValue(raw.addressCountry, 8),\n })\n if (address) row.address = address\n // The country is the one address part the normalizer drops silently — a\n // typed name is not a code — so it is the one part the report has to name.\n const countryText = importTextValue(raw.addressCountry, 64)\n if (countryText && !address?.country) drop('addressCountry', countryText)\n\n const ownerText = importTextValue(raw.ownerEmail, 320)\n if (ownerText) {\n const owner = normalizeContactEmail(ownerText)\n if (owner) row.ownerEmail = owner\n else drop('ownerEmail', ownerText)\n }\n\n const stageText = importTextValue(raw.lifecycleStage, 64)\n if (stageText) {\n const stage = parseContactImportLifecycleStage(stageText)\n if (stage) row.lifecycleStage = stage\n else drop('lifecycleStage', stageText)\n }\n\n const consentText = raw.marketingConsent\n if (consentText !== undefined && consentText !== null && String(consentText).trim()) {\n const flag = parseContactImportFlag(consentText)\n if (flag === null) drop('marketingConsent', consentText)\n else row.marketingConsent = flag\n }\n\n const custom =\n raw.custom && typeof raw.custom === 'object' && !Array.isArray(raw.custom)\n ? (raw.custom as Record<string, unknown>)\n : {}\n const definitions = new Map(fields.map((field) => [field.key, field]))\n for (const [key, value] of Object.entries(custom)) {\n const definition = definitions.get(key)\n if (!definition) {\n drop(customImportTarget(key), value)\n continue\n }\n const parsed = parseContactImportCustomValue(definition, value)\n if (!parsed) drop(customImportTarget(key), value)\n else if (parsed.value !== undefined) row.custom[key] = parsed.value\n }\n\n return { ok: true, row }\n}\n\n/** One row the server did not store, by its index in the request, named by address. */\nexport interface ContactImportSkippedRow {\n index: number\n email: string\n reason: ContactImportSkipReason\n}\n\n/**\n * What one request did — the shared tally plus how many companies the\n * company column brought into being. The drawer sums these across a file.\n */\nexport interface ContactImportChunkResult\n extends ImportChunkResult<ContactImportSkippedRow> {\n companiesCreated: number\n}\n\n/** An empty tally, for the drawer to fold chunk results into. */\nexport function emptyContactImportResult(): ContactImportChunkResult {\n return { ...emptyImportResult<ContactImportSkippedRow>(), companiesCreated: 0 }\n}\n\n/**\n * Two results as one — the shared fold, with the company tally beside it.\n * `offset` is where the chunk started in the file, so a skipped row's index\n * comes back as its position in the FILE rather than in the request.\n */\nexport function mergeContactImportResults(\n total: ContactImportChunkResult,\n chunk: ContactImportChunkResult,\n offset = 0,\n): ContactImportChunkResult {\n return {\n ...mergeImportResults(total, chunk, offset),\n companiesCreated: total.companiesCreated + Number(chunk.companiesCreated ?? 0),\n }\n}\n\n/**\n * The skipped rows as a file the operator can fix and re-import: the\n * original columns verbatim plus a trailing `Skipped because` column.\n */\nexport function contactImportSkippedCsv(\n columns: readonly string[],\n entries: readonly { cells: readonly string[]; reason: ContactImportSkipReason }[],\n): string {\n return importSkippedCsv(columns, entries, CONTACT_IMPORT_SKIP_LABELS)\n}\n"],"names":["normalizeAddress","normalizePhone","normalizeContactEmail","CSV_IMPORT_CHUNK_SIZE","CSV_IMPORT_MAX_BODY_BYTES","CSV_IMPORT_MAX_ROWS","CSV_IMPORT_PREVIEW_ROWS","customImportTarget","emptyImportResult","guessImportMapping","importAliasKeys","importSkippedCsv","importTextValue","mapImportRow","mergeImportResults","parseImportFlag","parseImportTags","CONTACT_LIFECYCLE_STAGE_LABELS","CONTACT_LIFECYCLE_STAGES","isContactLifecycleStage","customImportTargetKey","CONTACT_IMPORT_MAX_ROWS","CONTACT_IMPORT_CHUNK_SIZE","CONTACT_IMPORT_MAX_BODY_BYTES","CONTACT_IMPORT_PREVIEW_ROWS","CONTACT_IMPORT_TAGS_MAX","NAME_MAX","CUSTOM_TEXT_MAX","CONTACT_IMPORT_FIELDS","CONTACT_IMPORT_FIELD_LABELS","email","name","phone","jobTitle","companyName","addressLine1","addressLine2","addressCity","addressState","addressPostalCode","addressCountry","tags","ownerEmail","lifecycleStage","marketingConsent","FIELD_ALIASES","FIELD_ALIAS_KEYS","guessContactImportMapping","columns","fields","mapContactImportRow","cells","mapping","CONTACT_IMPORT_SKIP_LABELS","duplicate","erased","parseContactImportTags","value","parseContactImportFlag","parseContactImportLifecycleStage","text","String","trim","toLowerCase","key","replace","stage","parseContactImportCustomValue","definition","undefined","type","slice","number","Number","isFinite","ms","Date","parse","wanted","option","options","find","candidate","flag","normalizeContactImportRow","raw","ok","reason","input","dropped","drop","field","push","row","custom","phoneText","address","line1","line2","city","state","postalCode","country","countryText","ownerText","owner","stageText","consentText","Array","isArray","definitions","Map","map","Object","entries","get","parsed","emptyContactImportResult","companiesCreated","mergeContactImportResults","total","chunk","offset","contactImportSkippedCsv"],"mappings":"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"}
@@ -0,0 +1,162 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2026 Aglyn LLC
4
+ *
5
+ * Licensed under the Apache License, Version 2.0 (the "License");
6
+ * you may not use this file except in compliance with the License.
7
+ * You may obtain a copy of the License at
8
+ *
9
+ * http://www.apache.org/licenses/LICENSE-2.0
10
+ *
11
+ * Unless required by applicable law or agreed to in writing, software
12
+ * distributed under the License is distributed on an "AS IS" BASIS,
13
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ * See the License for the specific language governing permissions and
15
+ * limitations under the License.
16
+ */
17
+ import { type CrmActivity, type CrmActivityLink, type CrmEmailDirection } from '@aglyn/aglyn/app-utils/crm';
18
+ import { type MemberAddresses } from '@aglyn/aglyn/app-utils/member-email-aliases';
19
+ /**
20
+ * EMAIL CAPTURE (AGL-2657): the pure half.
21
+ *
22
+ * A workspace gets one address — `crm+<token>@<capture domain>` — and a
23
+ * reply forwarded to it, or a message a teammate copies to it from their
24
+ * own mailbox, is filed on the record of whoever the message was with.
25
+ * This module is everything about that which needs no Firestore and no
26
+ * provider: the shape of the address, the token in it, which of a
27
+ * message's addresses is the correspondent, and the activity row the whole
28
+ * thing becomes. How a message is read — the address in a header, the
29
+ * reply above the quoted history — is the platform's (`email-text.ts`), and
30
+ * so is the domain mail arrives on (`inbound-mail-domain.ts`). The webhook
31
+ * route and the address route read it; the specs read all of it.
32
+ *
33
+ * ## The token is the whole secret
34
+ *
35
+ * The address is public the moment a member pastes it into a mailbox rule,
36
+ * and the domain is the platform's, so the token is the only thing that
37
+ * says which workspace a message is for. Thirty-two characters from a
38
+ * lowercase alphabet — mail servers may fold the local part's case, and a
39
+ * token that survived folding is one that never depended on case — drawn
40
+ * from the platform's random source, which is the same entropy an API key
41
+ * carries. Rotation replaces it; the old address then files nothing.
42
+ *
43
+ * ## Who the message was with
44
+ *
45
+ * A message names several addresses and exactly one of them is the person
46
+ * the record is about. The rule: the first address among From, To and Cc
47
+ * that is not the capture address and not a member of the workspace — the
48
+ * workspace's own people are never the correspondent — in that order,
49
+ * because a reply's From is the correspondent and a copied send's To is.
50
+ * A member is every address that is theirs: the one they sign in with and
51
+ * each alias they have confirmed (`member-email-aliases.ts`), so a send
52
+ * from an outbound-domain alias is read as the member's, not as a stranger.
53
+ * A message a teammate forwarded names the correspondent nowhere in its
54
+ * headers, only in the forwarded block of the body, so that block's `From:`
55
+ * is the last candidate. Which candidate matched decides the direction:
56
+ * one the correspondent wrote is `inbound`, one the workspace wrote is
57
+ * `outbound`.
58
+ */
59
+ /** The local part every capture address begins with: `crm+<token>@…`. */
60
+ export declare const CRM_INBOUND_LOCAL_PART = "crm";
61
+ /** How many characters a minted token has. */
62
+ export declare const CRM_INBOUND_TOKEN_LENGTH = 32;
63
+ /** What the org's feed says about a message that matched no record. */
64
+ export declare const CRM_INBOUND_UNMATCHED_ACTION = "Inbound email matched no record";
65
+ /** What the org's feed says about a message the record's ceiling refused. */
66
+ export declare const CRM_INBOUND_CEILING_ACTION = "Inbound email not filed: activity log full";
67
+ /**
68
+ * A fresh token from the platform's random source. One byte per character
69
+ * folded onto a 36-symbol alphabet: the fold is very slightly uneven and
70
+ * the token is thirty-two symbols long, which is more than an API key
71
+ * carries and far more than an address anyone guesses.
72
+ */
73
+ export declare function mintCrmInboundToken(): string;
74
+ export declare function isCrmInboundToken(value: unknown): value is string;
75
+ /** `crm+<token>@<domain>` — the address a member pastes into a mailbox. */
76
+ export declare function crmInboundAddress(token: string, domain: string): string;
77
+ /**
78
+ * The token a recipient address carries, or `null` when the address is not
79
+ * a capture address on `domain`. The local part is read case-folded — a
80
+ * relay may fold it and the alphabet has no case to lose — and the domain
81
+ * is compared the way domains compare.
82
+ */
83
+ export declare function crmInboundTokenOf(recipient: unknown, domain: string): string | null;
84
+ /** Every distinct token among a message's recipients, in the order met. */
85
+ export declare function crmInboundTokensIn(recipients: readonly unknown[], domain: string): string[];
86
+ export declare function isCrmInboundAddress(address: unknown, domain: string): boolean;
87
+ /** One address the message might be with, in the order the rule tries them. */
88
+ export interface CrmInboundCandidate {
89
+ email: string;
90
+ /** What a row filed on a match with this address says about the message. */
91
+ direction: CrmEmailDirection;
92
+ /** Which header the address came from, for the spec and the log. */
93
+ via: 'from' | 'to' | 'cc' | 'forwarded';
94
+ }
95
+ export interface CrmInboundCandidatesInput {
96
+ from: unknown;
97
+ to: readonly unknown[];
98
+ cc?: readonly unknown[];
99
+ /** The forwarded block's `From:`, when the body carried one. */
100
+ forwardedFrom?: unknown;
101
+ /** The capture domain, so the capture address itself is never a candidate. */
102
+ domain: string;
103
+ /**
104
+ * The workspace's roster. Every address that is a member's — the one
105
+ * they sign in with and each alias they have confirmed — is theirs, and
106
+ * none of them is ever a correspondent.
107
+ */
108
+ members: readonly MemberAddresses[];
109
+ }
110
+ export interface CrmInboundCandidates {
111
+ /** The normalized From, or `null` when the header carried no address. */
112
+ sender: string | null;
113
+ /** Whether a member of the workspace wrote the message. */
114
+ senderIsMember: boolean;
115
+ candidates: CrmInboundCandidate[];
116
+ }
117
+ /**
118
+ * The addresses to try against the workspace's records, in order — see the
119
+ * module comment — each already stamped with the direction a match would
120
+ * mean: a message a member wrote went OUT to whoever it matches, and one
121
+ * anybody else wrote came IN from them.
122
+ */
123
+ export declare function crmInboundCandidates(input: CrmInboundCandidatesInput): CrmInboundCandidates;
124
+ /**
125
+ * The key one message is filed under, so a second delivery of it is a
126
+ * no-op: its `Message-ID`, which every mail client stamps and every relay
127
+ * preserves, else the provider's own id, which a redelivery of one
128
+ * webhook event repeats. `null` when the message has neither — a message
129
+ * that cannot be told from its own redelivery is filed by a fresh id.
130
+ */
131
+ export declare function crmCapturedEmailKey(messageId: unknown, providerId: unknown): string | null;
132
+ /** What a captured message is logged from — see {@link buildCrmCapturedEmailActivity}. */
133
+ export interface CrmCapturedEmailInput {
134
+ direction: CrmEmailDirection;
135
+ subject: string;
136
+ /** The excerpt, already reduced by `emailExcerpt`. */
137
+ excerpt: string;
138
+ /** The address on the message's From. */
139
+ from: string;
140
+ /** The address the message was addressed to, when one is worth showing. */
141
+ to?: string | null;
142
+ messageId: string;
143
+ inReplyTo?: string | null;
144
+ /** When the message was received. */
145
+ atMs: number;
146
+ /** The member who wrote it, or `''` for a message a correspondent wrote. */
147
+ byUid: string;
148
+ byName?: string | null;
149
+ link: CrmActivityLink;
150
+ hostId: string;
151
+ visibleTo: readonly string[];
152
+ }
153
+ /**
154
+ * The activity row a captured message is filed as (AGL-2657): `kind:
155
+ * 'email'`, with the direction the match decided, the excerpt for a body,
156
+ * and the thread facts a later reader groups by. No delivery state — the
157
+ * platform did not send it and can say nothing about where it got to —
158
+ * which is also how the timeline tells a captured send from a platform
159
+ * send. The same shape `buildCrmEmailActivity` writes, so the timeline
160
+ * draws both with one row.
161
+ */
162
+ export declare function buildCrmCapturedEmailActivity(input: CrmCapturedEmailInput): CrmActivity;
@@ -0,0 +1,208 @@
1
+ import { _ as _extends } from "@swc/helpers/_/_extends";
2
+ /**
3
+ * @license
4
+ * Copyright 2026 Aglyn LLC
5
+ *
6
+ * Licensed under the Apache License, Version 2.0 (the "License");
7
+ * you may not use this file except in compliance with the License.
8
+ * You may obtain a copy of the License at
9
+ *
10
+ * http://www.apache.org/licenses/LICENSE-2.0
11
+ *
12
+ * Unless required by applicable law or agreed to in writing, software
13
+ * distributed under the License is distributed on an "AS IS" BASIS,
14
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15
+ * See the License for the specific language governing permissions and
16
+ * limitations under the License.
17
+ */ import { CRM_EMAIL_BODY_MAX, CRM_EMAIL_SUBJECT_MAX } from "@aglyn/aglyn/app-utils/crm";
18
+ import { emailAddressOf, threadSubject } from "@aglyn/aglyn/app-utils/email-text";
19
+ import { memberEmailAddresses } from "@aglyn/aglyn/app-utils/member-email-aliases";
20
+ /**
21
+ * EMAIL CAPTURE (AGL-2657): the pure half.
22
+ *
23
+ * A workspace gets one address — `crm+<token>@<capture domain>` — and a
24
+ * reply forwarded to it, or a message a teammate copies to it from their
25
+ * own mailbox, is filed on the record of whoever the message was with.
26
+ * This module is everything about that which needs no Firestore and no
27
+ * provider: the shape of the address, the token in it, which of a
28
+ * message's addresses is the correspondent, and the activity row the whole
29
+ * thing becomes. How a message is read — the address in a header, the
30
+ * reply above the quoted history — is the platform's (`email-text.ts`), and
31
+ * so is the domain mail arrives on (`inbound-mail-domain.ts`). The webhook
32
+ * route and the address route read it; the specs read all of it.
33
+ *
34
+ * ## The token is the whole secret
35
+ *
36
+ * The address is public the moment a member pastes it into a mailbox rule,
37
+ * and the domain is the platform's, so the token is the only thing that
38
+ * says which workspace a message is for. Thirty-two characters from a
39
+ * lowercase alphabet — mail servers may fold the local part's case, and a
40
+ * token that survived folding is one that never depended on case — drawn
41
+ * from the platform's random source, which is the same entropy an API key
42
+ * carries. Rotation replaces it; the old address then files nothing.
43
+ *
44
+ * ## Who the message was with
45
+ *
46
+ * A message names several addresses and exactly one of them is the person
47
+ * the record is about. The rule: the first address among From, To and Cc
48
+ * that is not the capture address and not a member of the workspace — the
49
+ * workspace's own people are never the correspondent — in that order,
50
+ * because a reply's From is the correspondent and a copied send's To is.
51
+ * A member is every address that is theirs: the one they sign in with and
52
+ * each alias they have confirmed (`member-email-aliases.ts`), so a send
53
+ * from an outbound-domain alias is read as the member's, not as a stranger.
54
+ * A message a teammate forwarded names the correspondent nowhere in its
55
+ * headers, only in the forwarded block of the body, so that block's `From:`
56
+ * is the last candidate. Which candidate matched decides the direction:
57
+ * one the correspondent wrote is `inbound`, one the workspace wrote is
58
+ * `outbound`.
59
+ */ /** The local part every capture address begins with: `crm+<token>@…`. */ export const CRM_INBOUND_LOCAL_PART = 'crm';
60
+ /** How many characters a minted token has. */ export const CRM_INBOUND_TOKEN_LENGTH = 32;
61
+ /** What the org's feed says about a message that matched no record. */ export const CRM_INBOUND_UNMATCHED_ACTION = 'Inbound email matched no record';
62
+ /** What the org's feed says about a message the record's ceiling refused. */ export const CRM_INBOUND_CEILING_ACTION = 'Inbound email not filed: activity log full';
63
+ const TOKEN_ALPHABET = 'abcdefghijklmnopqrstuvwxyz0123456789';
64
+ const TOKEN_PATTERN = /^[a-z0-9]{24,64}$/;
65
+ /**
66
+ * A fresh token from the platform's random source. One byte per character
67
+ * folded onto a 36-symbol alphabet: the fold is very slightly uneven and
68
+ * the token is thirty-two symbols long, which is more than an API key
69
+ * carries and far more than an address anyone guesses.
70
+ */ export function mintCrmInboundToken() {
71
+ const bytes = new Uint8Array(CRM_INBOUND_TOKEN_LENGTH);
72
+ globalThis.crypto.getRandomValues(bytes);
73
+ let token = '';
74
+ for (const byte of bytes)token += TOKEN_ALPHABET[byte % TOKEN_ALPHABET.length];
75
+ return token;
76
+ }
77
+ export function isCrmInboundToken(value) {
78
+ return typeof value === 'string' && TOKEN_PATTERN.test(value);
79
+ }
80
+ /** `crm+<token>@<domain>` — the address a member pastes into a mailbox. */ export function crmInboundAddress(token, domain) {
81
+ return `${CRM_INBOUND_LOCAL_PART}+${token}@${domain}`;
82
+ }
83
+ /**
84
+ * The token a recipient address carries, or `null` when the address is not
85
+ * a capture address on `domain`. The local part is read case-folded — a
86
+ * relay may fold it and the alphabet has no case to lose — and the domain
87
+ * is compared the way domains compare.
88
+ */ export function crmInboundTokenOf(recipient, domain) {
89
+ const raw = String(recipient != null ? recipient : '').trim();
90
+ const angled = /<([^<>]+)>\s*$/.exec(raw);
91
+ const address = (angled ? angled[1] : raw).trim();
92
+ const at = address.lastIndexOf('@');
93
+ if (at <= 0) return null;
94
+ if (address.slice(at + 1).toLowerCase() !== domain.toLowerCase()) return null;
95
+ const local = address.slice(0, at).toLowerCase();
96
+ const match = new RegExp(`^${CRM_INBOUND_LOCAL_PART}\\+([a-z0-9]{24,64})$`).exec(local);
97
+ return match ? match[1] : null;
98
+ }
99
+ /** Every distinct token among a message's recipients, in the order met. */ export function crmInboundTokensIn(recipients, domain) {
100
+ const tokens = [];
101
+ for (const recipient of recipients){
102
+ const token = crmInboundTokenOf(recipient, domain);
103
+ if (token && !tokens.includes(token)) tokens.push(token);
104
+ }
105
+ return tokens;
106
+ }
107
+ export function isCrmInboundAddress(address, domain) {
108
+ return crmInboundTokenOf(address, domain) !== null;
109
+ }
110
+ /**
111
+ * The addresses to try against the workspace's records, in order — see the
112
+ * module comment — each already stamped with the direction a match would
113
+ * mean: a message a member wrote went OUT to whoever it matches, and one
114
+ * anybody else wrote came IN from them.
115
+ */ export function crmInboundCandidates(input) {
116
+ var _input_cc;
117
+ const members = new Set();
118
+ for (const member of input.members){
119
+ for (const address of memberEmailAddresses(member))members.add(address);
120
+ }
121
+ const sender = emailAddressOf(input.from);
122
+ const senderIsMember = sender !== null && members.has(sender);
123
+ const seen = new Set();
124
+ const candidates = [];
125
+ const consider = (value, direction, via)=>{
126
+ const email = emailAddressOf(value);
127
+ if (!email || seen.has(email) || members.has(email)) return;
128
+ if (isCrmInboundAddress(email, input.domain)) return;
129
+ seen.add(email);
130
+ candidates.push({
131
+ email,
132
+ direction,
133
+ via
134
+ });
135
+ };
136
+ consider(input.from, 'inbound', 'from');
137
+ const headed = senderIsMember ? 'outbound' : 'inbound';
138
+ for (const value of input.to)consider(value, headed, 'to');
139
+ for (const value of (_input_cc = input.cc) != null ? _input_cc : [])consider(value, headed, 'cc');
140
+ consider(input.forwardedFrom, 'inbound', 'forwarded');
141
+ return {
142
+ sender,
143
+ senderIsMember,
144
+ candidates
145
+ };
146
+ }
147
+ /**
148
+ * The key one message is filed under, so a second delivery of it is a
149
+ * no-op: its `Message-ID`, which every mail client stamps and every relay
150
+ * preserves, else the provider's own id, which a redelivery of one
151
+ * webhook event repeats. `null` when the message has neither — a message
152
+ * that cannot be told from its own redelivery is filed by a fresh id.
153
+ */ export function crmCapturedEmailKey(messageId, providerId) {
154
+ const id = String(messageId != null ? messageId : '').trim();
155
+ if (id) return `mid:${id}`;
156
+ const provider = String(providerId != null ? providerId : '').trim();
157
+ return provider ? `provider:${provider}` : null;
158
+ }
159
+ /**
160
+ * The activity row a captured message is filed as (AGL-2657): `kind:
161
+ * 'email'`, with the direction the match decided, the excerpt for a body,
162
+ * and the thread facts a later reader groups by. No delivery state — the
163
+ * platform did not send it and can say nothing about where it got to —
164
+ * which is also how the timeline tells a captured send from a platform
165
+ * send. The same shape `buildCrmEmailActivity` writes, so the timeline
166
+ * draws both with one row.
167
+ */ export function buildCrmCapturedEmailActivity(input) {
168
+ var _input_subject, _input_inReplyTo, _input_to, _input_byName, _input_excerpt, _input_messageId;
169
+ const { link } = input;
170
+ const subject = String((_input_subject = input.subject) != null ? _input_subject : '').replace(/\s+/g, ' ').trim().slice(0, CRM_EMAIL_SUBJECT_MAX);
171
+ const inReplyTo = String((_input_inReplyTo = input.inReplyTo) != null ? _input_inReplyTo : '').trim();
172
+ const to = String((_input_to = input.to) != null ? _input_to : '').trim();
173
+ const byName = String((_input_byName = input.byName) != null ? _input_byName : '').trim();
174
+ return _extends({
175
+ kind: 'email',
176
+ subject,
177
+ body: String((_input_excerpt = input.excerpt) != null ? _input_excerpt : '').slice(0, CRM_EMAIL_BODY_MAX),
178
+ direction: input.direction,
179
+ from: input.from
180
+ }, to ? {
181
+ to
182
+ } : {}, {
183
+ messageId: String((_input_messageId = input.messageId) != null ? _input_messageId : '').trim()
184
+ }, inReplyTo ? {
185
+ inReplyTo
186
+ } : {}, {
187
+ threadSubject: threadSubject(subject),
188
+ atMs: input.atMs,
189
+ byUid: input.byUid
190
+ }, byName ? {
191
+ byName
192
+ } : {}, link.contactId ? {
193
+ contactId: String(link.contactId)
194
+ } : {}, link.companyId ? {
195
+ companyId: String(link.companyId)
196
+ } : {}, link.dealId ? {
197
+ dealId: String(link.dealId)
198
+ } : {}, link.leadId ? {
199
+ leadId: String(link.leadId)
200
+ } : {}, {
201
+ hostId: input.hostId,
202
+ visibleTo: [
203
+ ...input.visibleTo
204
+ ]
205
+ });
206
+ }
207
+
208
+ //# sourceMappingURL=crm-inbound.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/model/crm-inbound.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport {\n CRM_EMAIL_BODY_MAX,\n CRM_EMAIL_SUBJECT_MAX,\n type CrmActivity,\n type CrmActivityLink,\n type CrmEmailDirection,\n} from '@aglyn/aglyn/app-utils/crm'\nimport { emailAddressOf, threadSubject } from '@aglyn/aglyn/app-utils/email-text'\nimport {\n type MemberAddresses,\n memberEmailAddresses,\n} from '@aglyn/aglyn/app-utils/member-email-aliases'\n\n/**\n * EMAIL CAPTURE (AGL-2657): the pure half.\n *\n * A workspace gets one address — `crm+<token>@<capture domain>` — and a\n * reply forwarded to it, or a message a teammate copies to it from their\n * own mailbox, is filed on the record of whoever the message was with.\n * This module is everything about that which needs no Firestore and no\n * provider: the shape of the address, the token in it, which of a\n * message's addresses is the correspondent, and the activity row the whole\n * thing becomes. How a message is read — the address in a header, the\n * reply above the quoted history — is the platform's (`email-text.ts`), and\n * so is the domain mail arrives on (`inbound-mail-domain.ts`). The webhook\n * route and the address route read it; the specs read all of it.\n *\n * ## The token is the whole secret\n *\n * The address is public the moment a member pastes it into a mailbox rule,\n * and the domain is the platform's, so the token is the only thing that\n * says which workspace a message is for. Thirty-two characters from a\n * lowercase alphabet — mail servers may fold the local part's case, and a\n * token that survived folding is one that never depended on case — drawn\n * from the platform's random source, which is the same entropy an API key\n * carries. Rotation replaces it; the old address then files nothing.\n *\n * ## Who the message was with\n *\n * A message names several addresses and exactly one of them is the person\n * the record is about. The rule: the first address among From, To and Cc\n * that is not the capture address and not a member of the workspace — the\n * workspace's own people are never the correspondent — in that order,\n * because a reply's From is the correspondent and a copied send's To is.\n * A member is every address that is theirs: the one they sign in with and\n * each alias they have confirmed (`member-email-aliases.ts`), so a send\n * from an outbound-domain alias is read as the member's, not as a stranger.\n * A message a teammate forwarded names the correspondent nowhere in its\n * headers, only in the forwarded block of the body, so that block's `From:`\n * is the last candidate. Which candidate matched decides the direction:\n * one the correspondent wrote is `inbound`, one the workspace wrote is\n * `outbound`.\n */\n\n/** The local part every capture address begins with: `crm+<token>@…`. */\nexport const CRM_INBOUND_LOCAL_PART = 'crm'\n\n/** How many characters a minted token has. */\nexport const CRM_INBOUND_TOKEN_LENGTH = 32\n\n/** What the org's feed says about a message that matched no record. */\nexport const CRM_INBOUND_UNMATCHED_ACTION = 'Inbound email matched no record'\n\n/** What the org's feed says about a message the record's ceiling refused. */\nexport const CRM_INBOUND_CEILING_ACTION = 'Inbound email not filed: activity log full'\n\nconst TOKEN_ALPHABET = 'abcdefghijklmnopqrstuvwxyz0123456789'\nconst TOKEN_PATTERN = /^[a-z0-9]{24,64}$/\n\n/**\n * A fresh token from the platform's random source. One byte per character\n * folded onto a 36-symbol alphabet: the fold is very slightly uneven and\n * the token is thirty-two symbols long, which is more than an API key\n * carries and far more than an address anyone guesses.\n */\nexport function mintCrmInboundToken(): string {\n const bytes = new Uint8Array(CRM_INBOUND_TOKEN_LENGTH)\n globalThis.crypto.getRandomValues(bytes)\n let token = ''\n for (const byte of bytes) token += TOKEN_ALPHABET[byte % TOKEN_ALPHABET.length]\n return token\n}\n\nexport function isCrmInboundToken(value: unknown): value is string {\n return typeof value === 'string' && TOKEN_PATTERN.test(value)\n}\n\n/** `crm+<token>@<domain>` — the address a member pastes into a mailbox. */\nexport function crmInboundAddress(token: string, domain: string): string {\n return `${CRM_INBOUND_LOCAL_PART}+${token}@${domain}`\n}\n\n/**\n * The token a recipient address carries, or `null` when the address is not\n * a capture address on `domain`. The local part is read case-folded — a\n * relay may fold it and the alphabet has no case to lose — and the domain\n * is compared the way domains compare.\n */\nexport function crmInboundTokenOf(recipient: unknown, domain: string): string | null {\n const raw = String(recipient ?? '').trim()\n const angled = /<([^<>]+)>\\s*$/.exec(raw)\n const address = (angled ? angled[1] : raw).trim()\n const at = address.lastIndexOf('@')\n if (at <= 0) return null\n if (address.slice(at + 1).toLowerCase() !== domain.toLowerCase()) return null\n const local = address.slice(0, at).toLowerCase()\n const match = new RegExp(`^${CRM_INBOUND_LOCAL_PART}\\\\+([a-z0-9]{24,64})$`).exec(local)\n return match ? match[1] : null\n}\n\n/** Every distinct token among a message's recipients, in the order met. */\nexport function crmInboundTokensIn(\n recipients: readonly unknown[],\n domain: string,\n): string[] {\n const tokens: string[] = []\n for (const recipient of recipients) {\n const token = crmInboundTokenOf(recipient, domain)\n if (token && !tokens.includes(token)) tokens.push(token)\n }\n return tokens\n}\n\nexport function isCrmInboundAddress(address: unknown, domain: string): boolean {\n return crmInboundTokenOf(address, domain) !== null\n}\n\n/** One address the message might be with, in the order the rule tries them. */\nexport interface CrmInboundCandidate {\n email: string\n /** What a row filed on a match with this address says about the message. */\n direction: CrmEmailDirection\n /** Which header the address came from, for the spec and the log. */\n via: 'from' | 'to' | 'cc' | 'forwarded'\n}\n\nexport interface CrmInboundCandidatesInput {\n from: unknown\n to: readonly unknown[]\n cc?: readonly unknown[]\n /** The forwarded block's `From:`, when the body carried one. */\n forwardedFrom?: unknown\n /** The capture domain, so the capture address itself is never a candidate. */\n domain: string\n /**\n * The workspace's roster. Every address that is a member's — the one\n * they sign in with and each alias they have confirmed — is theirs, and\n * none of them is ever a correspondent.\n */\n members: readonly MemberAddresses[]\n}\n\nexport interface CrmInboundCandidates {\n /** The normalized From, or `null` when the header carried no address. */\n sender: string | null\n /** Whether a member of the workspace wrote the message. */\n senderIsMember: boolean\n candidates: CrmInboundCandidate[]\n}\n\n/**\n * The addresses to try against the workspace's records, in order — see the\n * module comment — each already stamped with the direction a match would\n * mean: a message a member wrote went OUT to whoever it matches, and one\n * anybody else wrote came IN from them.\n */\nexport function crmInboundCandidates(input: CrmInboundCandidatesInput): CrmInboundCandidates {\n const members = new Set<string>()\n for (const member of input.members) {\n for (const address of memberEmailAddresses(member)) members.add(address)\n }\n const sender = emailAddressOf(input.from)\n const senderIsMember = sender !== null && members.has(sender)\n const seen = new Set<string>()\n const candidates: CrmInboundCandidate[] = []\n const consider = (\n value: unknown,\n direction: CrmEmailDirection,\n via: CrmInboundCandidate['via'],\n ) => {\n const email = emailAddressOf(value)\n if (!email || seen.has(email) || members.has(email)) return\n if (isCrmInboundAddress(email, input.domain)) return\n seen.add(email)\n candidates.push({ email, direction, via })\n }\n consider(input.from, 'inbound', 'from')\n const headed = senderIsMember ? 'outbound' : 'inbound'\n for (const value of input.to) consider(value, headed, 'to')\n for (const value of input.cc ?? []) consider(value, headed, 'cc')\n consider(input.forwardedFrom, 'inbound', 'forwarded')\n return { sender, senderIsMember, candidates }\n}\n\n/**\n * The key one message is filed under, so a second delivery of it is a\n * no-op: its `Message-ID`, which every mail client stamps and every relay\n * preserves, else the provider's own id, which a redelivery of one\n * webhook event repeats. `null` when the message has neither — a message\n * that cannot be told from its own redelivery is filed by a fresh id.\n */\nexport function crmCapturedEmailKey(messageId: unknown, providerId: unknown): string | null {\n const id = String(messageId ?? '').trim()\n if (id) return `mid:${id}`\n const provider = String(providerId ?? '').trim()\n return provider ? `provider:${provider}` : null\n}\n\n/** What a captured message is logged from — see {@link buildCrmCapturedEmailActivity}. */\nexport interface CrmCapturedEmailInput {\n direction: CrmEmailDirection\n subject: string\n /** The excerpt, already reduced by `emailExcerpt`. */\n excerpt: string\n /** The address on the message's From. */\n from: string\n /** The address the message was addressed to, when one is worth showing. */\n to?: string | null\n messageId: string\n inReplyTo?: string | null\n /** When the message was received. */\n atMs: number\n /** The member who wrote it, or `''` for a message a correspondent wrote. */\n byUid: string\n byName?: string | null\n link: CrmActivityLink\n hostId: string\n visibleTo: readonly string[]\n}\n\n/**\n * The activity row a captured message is filed as (AGL-2657): `kind:\n * 'email'`, with the direction the match decided, the excerpt for a body,\n * and the thread facts a later reader groups by. No delivery state — the\n * platform did not send it and can say nothing about where it got to —\n * which is also how the timeline tells a captured send from a platform\n * send. The same shape `buildCrmEmailActivity` writes, so the timeline\n * draws both with one row.\n */\nexport function buildCrmCapturedEmailActivity(input: CrmCapturedEmailInput): CrmActivity {\n const { link } = input\n const subject = String(input.subject ?? '')\n .replace(/\\s+/g, ' ')\n .trim()\n .slice(0, CRM_EMAIL_SUBJECT_MAX)\n const inReplyTo = String(input.inReplyTo ?? '').trim()\n const to = String(input.to ?? '').trim()\n const byName = String(input.byName ?? '').trim()\n return {\n kind: 'email',\n subject,\n body: String(input.excerpt ?? '').slice(0, CRM_EMAIL_BODY_MAX),\n direction: input.direction,\n from: input.from,\n ...(to ? { to } : {}),\n messageId: String(input.messageId ?? '').trim(),\n ...(inReplyTo ? { inReplyTo } : {}),\n threadSubject: threadSubject(subject),\n atMs: input.atMs,\n byUid: input.byUid,\n ...(byName ? { byName } : {}),\n ...(link.contactId ? { contactId: String(link.contactId) } : {}),\n ...(link.companyId ? { companyId: String(link.companyId) } : {}),\n ...(link.dealId ? { dealId: String(link.dealId) } : {}),\n ...(link.leadId ? { leadId: String(link.leadId) } : {}),\n hostId: input.hostId,\n visibleTo: [...input.visibleTo],\n }\n}\n"],"names":["CRM_EMAIL_BODY_MAX","CRM_EMAIL_SUBJECT_MAX","emailAddressOf","threadSubject","memberEmailAddresses","CRM_INBOUND_LOCAL_PART","CRM_INBOUND_TOKEN_LENGTH","CRM_INBOUND_UNMATCHED_ACTION","CRM_INBOUND_CEILING_ACTION","TOKEN_ALPHABET","TOKEN_PATTERN","mintCrmInboundToken","bytes","Uint8Array","globalThis","crypto","getRandomValues","token","byte","length","isCrmInboundToken","value","test","crmInboundAddress","domain","crmInboundTokenOf","recipient","raw","String","trim","angled","exec","address","at","lastIndexOf","slice","toLowerCase","local","match","RegExp","crmInboundTokensIn","recipients","tokens","includes","push","isCrmInboundAddress","crmInboundCandidates","input","members","Set","member","add","sender","from","senderIsMember","has","seen","candidates","consider","direction","via","email","headed","to","cc","forwardedFrom","crmCapturedEmailKey","messageId","providerId","id","provider","buildCrmCapturedEmailActivity","link","subject","replace","inReplyTo","byName","kind","body","excerpt","atMs","byUid","contactId","companyId","dealId","leadId","hostId","visibleTo"],"mappings":"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"}
@@ -27,7 +27,7 @@ export declare const LEAD_IMPORT_REASON_MAX = 500;
27
27
  /**
28
28
  * The statuses a file may set.
29
29
  *
30
- * The list's own status control offers exactly these three, and for the
30
+ * The list's own status control offers exactly these four, and for the
31
31
  * reason given above: `qualified` is written by the conversion, beside the
32
32
  * contact it produced.
33
33
  */
@@ -64,7 +64,7 @@ import { _ as _object_without_properties_loose } from "@swc/helpers/_/_object_wi
64
64
  * to belong to, is left off the lead and REPORTED under
65
65
  * {@link LeadImportRow.dropped}, so the operator learns that a column went
66
66
  * nowhere rather than discovering it weeks later.
67
- */ import { CAMPAIGN_MEMBERSHIP_CAP } from "@aglyn/aglyn/app-utils/campaign-membership";
67
+ */ import { CONTAINER_MEMBERSHIP_CAP } from "@aglyn/aglyn/app-utils/container-membership";
68
68
  import { normalizeContactEmail } from "@aglyn/aglyn/app-utils/contacts";
69
69
  import { CRM_LEAD_STATUS_LABELS, CRM_LEAD_STATUSES, CRM_LEAD_TEXT_MAX, isCrmLeadStatus, judgeCrmLeadSource, normalizeCrmPicklistLabel, normalizeCrmLeadTags, normalizeCompanyWebsite } from "@aglyn/aglyn/app-utils/crm";
70
70
  import { normalizeAddress, normalizePhone } from "@aglyn/aglyn/foundation/definitions/contact.types";
@@ -79,7 +79,7 @@ export const LEAD_IMPORT_REASON_MAX = 500;
79
79
  /**
80
80
  * The statuses a file may set.
81
81
  *
82
- * The list's own status control offers exactly these three, and for the
82
+ * The list's own status control offers exactly these four, and for the
83
83
  * reason given above: `qualified` is written by the conversion, beside the
84
84
  * contact it produced.
85
85
  */ export const LEAD_IMPORT_STATUSES = CRM_LEAD_STATUSES.filter((status)=>status !== 'qualified');
@@ -121,7 +121,7 @@ export const LEAD_IMPORT_REASON_MAX = 500;
121
121
  phone: 'Phone',
122
122
  website: 'Website',
123
123
  leadSource: 'Lead source',
124
- status: 'Status (new, working, unqualified)',
124
+ status: 'Status (new, nurturing, working, unqualified)',
125
125
  ownerEmail: 'Owner (team member email)',
126
126
  addressLine1: 'Address line 1',
127
127
  addressLine2: 'Address line 2',
@@ -296,7 +296,7 @@ const FIELD_ALIAS_KEYS = importAliasKeys(LEAD_IMPORT_FIELDS, FIELD_ALIASES);
296
296
  'lead-source-unknown': "Names a lead source that isn't one of this organization's active values",
297
297
  'write-failed': 'Could not be saved'
298
298
  };
299
- /** The most campaigns one row may name — the membership field's own cap. */ export const LEAD_IMPORT_CAMPAIGNS_MAX = CAMPAIGN_MEMBERSHIP_CAP;
299
+ /** The most campaigns one row may name — the membership field's own cap. */ export const LEAD_IMPORT_CAMPAIGNS_MAX = CONTAINER_MEMBERSHIP_CAP;
300
300
  /**
301
301
  * A status cell by id or by label — `working`, `Working` and `WORKING` are
302
302
  * one status. `null` for a cell naming no status a file may set, which