ldap-rest 0.17.0 → 0.18.0

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 (76) hide show
  1. package/dist/abstract/ldapFlat.js +1 -1
  2. package/dist/abstract/twakePlugin.js +1 -1
  3. package/dist/bin/index.js +1 -1
  4. package/dist/config/args.js +1 -1
  5. package/dist/config/schema.js +1 -1
  6. package/dist/lib/ldapActions.js +1 -1
  7. package/dist/plugins/ldap/enterpriseRules.js +1 -1
  8. package/dist/plugins/ldap/groups.js +1 -1
  9. package/dist/plugins/ldap/onChange.js +1 -1
  10. package/dist/plugins/ldap/organizations.js +1 -1
  11. package/dist/plugins/twake/james.js +1 -1
  12. package/dist/src/abstract/ldapFlat.d.ts +9 -11
  13. package/dist/src/abstract/twakePlugin.d.ts +18 -1
  14. package/dist/src/bin/index.d.ts +10 -0
  15. package/dist/src/browser/ldap-group-editor/types.d.ts +2 -1
  16. package/dist/src/browser/ldap-resource-editor/types.d.ts +3 -2
  17. package/dist/src/browser/ldap-unit-editor/types.d.ts +2 -1
  18. package/dist/src/browser/ldap-user-editor/types.d.ts +3 -2
  19. package/dist/src/browser/shared/types/index.d.ts +7 -1
  20. package/dist/src/browser/shared/utils/schema.d.ts +20 -1
  21. package/dist/src/config/args.d.ts +2 -0
  22. package/dist/src/config/schema.d.ts +27 -4
  23. package/dist/src/hooks.d.ts +3 -1
  24. package/dist/src/plugins/ldap/organizations.d.ts +0 -8
  25. package/dist/src/plugins/twake/james.d.ts +12 -74
  26. package/docs/client-development/api/reference.md +1 -1
  27. package/docs/client-development/api/rest-api.md +11 -1
  28. package/docs/client-development/schemas/README.md +18 -13
  29. package/docs/plugin-development/dependencies.md +7 -3
  30. package/docs/presentations/en.md +0 -28
  31. package/docs/presentations/fr.md +0 -28
  32. package/docs/usage/configuration.md +14 -10
  33. package/docs/usage/plugins/integrations/james-mail.md +51 -115
  34. package/docs/usage/plugins/integrations/james-mailboxes.md +58 -78
  35. package/docs/usage/plugins/ldap/flat-generic.md +23 -5
  36. package/docs/usage/plugins/ldap/on-change.md +6 -0
  37. package/docs/usage/plugins/utilities/weblogs.md +1 -1
  38. package/docs/usage/upgrading.md +30 -0
  39. package/package.json +1 -1
  40. package/static/browser/ldap-resource-editor/api/ResourceApiClient.js +2 -1
  41. package/static/browser/ldap-resource-editor/api/ResourceApiClient.js.map +1 -1
  42. package/static/browser/ldap-user-editor/api/UserApiClient.js +2 -1
  43. package/static/browser/ldap-user-editor/api/UserApiClient.js.map +1 -1
  44. package/static/browser/ldap-user-editor/components/UserEditor.js +11 -8
  45. package/static/browser/ldap-user-editor/components/UserEditor.js.map +1 -1
  46. package/static/browser/ldap-user-editor/components/UserList.js +3 -1
  47. package/static/browser/ldap-user-editor/components/UserList.js.map +1 -1
  48. package/static/browser/ldap-user-editor.esm.js +52 -10
  49. package/static/browser/ldap-user-editor.esm.js.map +1 -1
  50. package/static/browser/ldap-user-editor.js +52 -10
  51. package/static/browser/ldap-user-editor.js.map +1 -1
  52. package/static/browser/ldap-user-editor.min.js +1 -1
  53. package/static/browser/ldap-user-editor.min.js.map +1 -1
  54. package/static/browser/shared/index.js +1 -1
  55. package/static/browser/shared/types/index.js.map +1 -1
  56. package/static/browser/shared/utils/form.js +2 -1
  57. package/static/browser/shared/utils/form.js.map +1 -1
  58. package/static/browser/shared/utils/schema.js +33 -1
  59. package/static/browser/shared/utils/schema.js.map +1 -1
  60. package/static/browser/shared.esm.js +153 -121
  61. package/static/browser/shared.esm.js.map +1 -1
  62. package/static/browser/shared.js +154 -120
  63. package/static/browser/shared.js.map +1 -1
  64. package/static/browser/shared.min.js +1 -1
  65. package/static/browser/shared.min.js.map +1 -1
  66. package/static/schemas/standard/groups.json +1 -1
  67. package/static/schemas/twake/groups.json +25 -7
  68. package/static/schemas/twake/nomenclature/twakeAccountStatus.json +10 -2
  69. package/static/schemas/twake/nomenclature/twakeDeliveryMode.json +10 -2
  70. package/static/schemas/twake/nomenclature/twakeDomain.json +9 -3
  71. package/static/schemas/twake/nomenclature/twakeListType.json +10 -2
  72. package/static/schemas/twake/nomenclature/twakeMailboxType.json +10 -2
  73. package/static/schemas/twake/nomenclature/twakeTitle.json +10 -2
  74. package/static/schemas/twake/organizations.json +4 -1
  75. package/static/schemas/twake/positions.json +4 -1
  76. package/static/schemas/twake/users.json +36 -9
@@ -1 +1 @@
1
- import t from"../../abstract/plugin.js";import{tryMethodData as i,jsonBody as a,tryMethod as e,wantJson as n}from"../../lib/expressFormatedResponses.js";import{asyncHandler as s,validateDnValue as r,escapeDnValue as o,getRdn as h,isDnInBranch as c,rdnValue as d,normalizeDn as l,getParentDn as g,escapeLdapFilter as p,launchHooksChained as f,isChildOf as u,substringSearchFilter as w}from"../../lib/utils.js";import{BadRequestError as m,NotFoundError as z,ConflictError as y,HttpError as b}from"../../lib/errors.js";import{extractLdapCode as $}from"../../lib/ldapCodes.js";import{loadSchemaFileAsync as _,schemaUrl as v}from"../../lib/schemaFile.js";import{assertClientMaySet as O,modifiedAttributeNames as C,missingRequiredAttribute as E,checkDnValues as A,matchesPattern as L}from"../../config/schema.js";class LdapOrganizations extends t{constructor(t){if(super(t),this.name="ldapOrganizations",this.roles=["api","consistency","configurable"],this.hooks={ldapaddrequest:async([t,i,a])=>(this.isOu(i)||await this.checkDeptLink(i),this.isOu(i)&&await this.checkDeptPath(i,t),void 0!==a?[t,i,a]:[t,i]),ldapmodifyrequest:async([t,i,a,e])=>{let n,s={},r={};const checkIsOu=async()=>{if(void 0!==n)return n;if(i.replace?.objectClass)n=this.isOu({objectClass:i.replace.objectClass});else if(i.add?.objectClass)n=this.isOu({objectClass:i.add.objectClass});else{const i=await this.server.ldap.search({paged:!1,scope:"base"},t);n=i.searchEntries.length>0&&this.isOu(i.searchEntries[0])}return n};if(i.delete){const t=(Array.isArray(i.delete)?i.delete.map(String):Object.keys(i.delete)).map(t=>t.split(";")[0].toLowerCase()),a=t.includes(this.linkAttr.toLowerCase()),e=t.includes(this.pathAttr.toLowerCase());if(a||e){if(!await checkIsOu()&&a)throw new m("An organization link cannot be deleted");if(e)throw new m("An organization path cannot be deleted")}}if(i.replace&&(i.replace[this.linkAttr]&&(s={...i.replace}),i.replace[this.pathAttr]&&(r={...i.replace})),i.add&&(i.add[this.linkAttr]&&(s={...s,...i.add}),i.add[this.pathAttr]&&(r={...r,...i.add})),Object.keys(s).length>0){await checkIsOu()||await this.checkDeptLink(s)}if(Object.keys(r).length>0){await checkIsOu()&&await this.checkDeptPath(r,t)}return[t,i,a,e]},ldapdeleterequest:async([t,i])=>{const a=Array.isArray(t)?t:[t];for(const t of a)/^ou=/.test(t)&&await this.isEmptyOrganization(t);return[t,i]},ldaprenamerequest:([t,i,a])=>[t,i,a]},this.sizeLimitWarned=new Set,!this.config.ldap_top_organization)throw new Error("Missing --ldap-top-organization");if(this.pathAttr=this.config.ldap_organization_path_attribute,this.linkAttr=this.config.ldap_organization_link_attribute,t.ldap.declareObjectClasses(this.config.ldap_top_organization,this.config.ldap_organization_class||[],!0),this.config.organization_schema){const t=this.config.organization_schema;_(t,{config:this.config}).then(t=>{this.schema=t,this.logger.debug("Organization schema loaded")}).catch(i=>this.logger.error(`Failed to load organization schema from ${t}: ${i}`))}}api(t){t.get(`${this.config.api_prefix}/v1/ldap/organizations/top`,async(t,a)=>{await i(a,async()=>this.hideNeverReturn(await this.getOrganisationTop(t)))}),t.get(`${this.config.api_prefix}/v1/ldap/organizations/:dn`,async(t,a)=>{const e=decodeURIComponent(t.params.dn);await i(a,async()=>this.hideNeverReturn(await this.getOrganisationByDn(e,t)))}),t.get(`${this.config.api_prefix}/v1/ldap/organizations/:dn/subnodes`,async(t,a)=>{const e=decodeURIComponent(t.params.dn);await i(a,async()=>this.hideNeverReturn(await this.getOrganisationSubnodes(e,t)))}),t.get(`${this.config.api_prefix}/v1/ldap/organizations/:dn/subnodes/search`,s(async(t,a)=>{const e=decodeURIComponent(t.params.dn),n=t.query.q;if(!n)throw new m('query parameter "q" is required');await i(a,async()=>this.hideNeverReturn(await this.searchOrganisationSubnodes(e,n,t)))})),t.get(`${this.config.api_prefix}/v1/ldap/organizations/:dn/search`,s(async(t,a)=>{const e=decodeURIComponent(t.params.dn),n=t.query.q;if(!n)throw new m('query parameter "q" is required');await i(a,async()=>this.hideNeverReturn(await this.searchOrganisationTree(e,n,t)))})),t.post(`${this.config.api_prefix}/v1/ldap/organizations`,s(async(t,i)=>this.apiAdd(t,i))),t.put(`${this.config.api_prefix}/v1/ldap/organizations/:dn`,s(async(t,i)=>this.apiModify(t,i))),t.delete(`${this.config.api_prefix}/v1/ldap/organizations/:dn`,s(async(t,i)=>this.apiDelete(t,i))),t.post(`${this.config.api_prefix}/v1/ldap/organizations/:dn/move`,s(async(t,i)=>this.apiMove(t,i)))}async apiAdd(t,i){const n=a(t,i,"ou");if(!n)return;O(this.schema,Object.keys(n),"ou"),r(n.ou,"ou");const s=n.parentDn||this.config.ldap_top_organization,h=`ou=${o(n.ou)},${s}`,c={objectClass:this.config.ldap_organization_class,ou:n.ou,...Object.fromEntries(Object.entries(n).filter(([t])=>"ou"!==t&&"parentDn"!==t))};await e(i,this.addOrganization.bind(this),h,c,t)}async apiModify(t,i){const n=a(t,i);if(!n)return;const s=decodeURIComponent(t.params.dn);if(!s)throw new m("dn is required");O(this.schema,C(n)),await e(i,this.modifyOrganization.bind(this),s,n,t)}async apiDelete(t,i){if(!n(t,i))return;const a=decodeURIComponent(t.params.dn);if(!a)throw new m("dn is required");await e(i,this.deleteOrganization.bind(this),a,t)}async apiMove(t,e){if(!n(t,e))return;const s=a(t,e,"targetOrgDn");if(!s)return;const r=decodeURIComponent(t.params.dn);if(!r)throw new m("dn is required");const{targetOrgDn:o}=s;if(!o||"string"!=typeof o)throw new m("Missing or invalid targetOrgDn in request body");await i(e,this.moveOrganization.bind(this),r,o,t)}async checkDeptLink(t){if(t[this.linkAttr]){const i=t[this.linkAttr],a=Array.isArray(i)?i[0]:i,e=await this.server.ldap.search({paged:!1,scope:"base"},a);if(0===e.searchEntries.length)throw new z(`Organization ${a} does not exist`);if(!new RegExp(`(.*,)?${this.config.ldap_top_organization}`).test(e.searchEntries[0].dn))throw new m(`Entry ${a} isn't in top organization branch`)}}async checkDeptPath(t,i){if(t[this.pathAttr]){const a=t[this.pathAttr],e=Array.isArray(a)?a[0]:a,n=this.config.ldap_organization_path_separator||" / ",s=this.config.ldap_top_organization||"",r=i&&s&&/^ou=/i.test(h(i))&&c(i,s)?d(i):void 0,o=t.ou,f=(Array.isArray(o)?o[0]:o)||r;let u=e;if((this.isOu(t)||void 0!==r)&&f){const t=s?d(s):"";if(t&&e.startsWith(f+n)&&e.endsWith(n+t))return;if(e===f){const t=s?l(s):"";if(!i||!t||!c(i,s)||l(i)===t||l(g(i))===t)return;throw new m(`Organization path "${e}" names no parent, but ${i} is not directly under ${s}`)}if(!e.endsWith(n+f))throw new m(`Organization path must end with its own name, preceded by separator "${n}"`);u=e.slice(0,e.length-n.length-f.length)}const w=await this.server.ldap.search({paged:!1,scope:"sub",filter:`(${this.pathAttr}=${p(u)})`,attributes:["dn"]},this.config.ldap_top_organization);if(!w.searchEntries||0===w.searchEntries.length)throw new m(`Invalid organization path ${e}: no organization holds "${u}"`)}}async isEmptyOrganization(t){if((await this.server.ldap.search({paged:!1,filter:`(${this.config.ldap_organization_link_attribute}=${p(t)})`})).searchEntries.length>0)throw new y(`Organization ${t} is not empty`)}isOu(t){if(!t.objectClass)return!1;const i=[].concat(t.objectClass).map(t=>String(t).toLowerCase());return this.config.ldap_organization_class.filter(t=>"top"!==t.toLowerCase()).some(t=>i.includes(t.toLowerCase()))}async getOrganisationTop(t){if(!this.config.ldap_top_organization)throw new m("No top organization configured");const i=await this.server.ldap.search({paged:!1,scope:"base"},this.config.ldap_top_organization,t);if(1!==i.searchEntries.length)throw new z("Top organization not found");const[,a]=await f(this.registeredHooks.getOrganisationTop,[t,i.searchEntries[0]]);return a}async getOrganisationByDn(t,i){const a=await this.server.ldap.search({paged:!1,scope:"base",filter:"(objectClass=organizationalUnit)"},t,i);if(1!==a.searchEntries.length)throw new z(`Organization ${t} not found`);return a.searchEntries[0]}async getOrganisationSubnodes(t,i){const a=[],e=this.config.ldap_organization_max_subnodes||50,n=i?.query?.objectClass;n&&"organizationalUnit"!==n||a.push(...await this.childOrganizations(t,"(objectClass=organizationalUnit)",e,i));const s=this.config.ldap_top_organization,r=g(s);let o=`(${this.config.ldap_organization_link_attribute}=${p(t)})`;return n&&(o=`(&${o}(objectClass=${p(n)}))`),this.server.logger.debug(`Searching for linked entities with filter: ${o} in ${r}`),a.push(...await this.linkedEntities(r,o,e,t,i)),a}warnSizeLimit(t,i){this.sizeLimitWarned.has(t)?this.server.logger.debug(i):(this.sizeLimitWarned.size<LdapOrganizations.SIZE_LIMIT_WARNED_MAX&&this.sizeLimitWarned.add(t),this.server.logger.warn(i))}static moreIndicator(t,i,a,e){return{dn:t,cn:[i],objectClass:["moreIndicator"],_isMoreIndicator:"true",...void 0===e?{}:{_totalCount:e.toString()},_displayedCount:a.toString()}}async childOrganizations(t,i,a,e){try{const a=await this.server.ldap.search({paged:!0,scope:"one",filter:i},t,e),n=[];for await(const t of a)n.push(...t.searchEntries);return this.server.logger.debug(`Found ${n.length} sub-OUs for ${t}`),n}catch(i){const a=$(i);if(32===a)return this.server.logger.debug(`No sub-OUs for ${t}: the node holds none`),[];if(4!==a)throw i}const n=(await this.server.ldap.search({paged:!1,scope:"one",filter:i,sizeLimit:a+1},t,e)).searchEntries.slice(0,a);return this.warnSizeLimit(`children:${t}`,`Organization ${t} holds more child organizations than the directory will list in one answer, so ${n.length} are returned and the rest are hidden. Raise the size limit for the account ldap-rest binds as (olcLimits, or olcSizeLimit) to list them all.`),[...n,LdapOrganizations.moreIndicator(`more-organizations-${t}`,"... more organizations than the directory will list",n.length)]}async linkedEntities(t,i,a,e,n){const s=[];let r=0;try{const e=await this.server.ldap.search({paged:!0,filter:i},t,n);for await(const t of e){r+=t.searchEntries.length;const i=a-s.length;i>0&&s.push(...t.searchEntries.slice(0,i))}}catch(s){if(4!==$(s))throw s;const r=await this.server.ldap.search({paged:!1,filter:i,sizeLimit:a+1},t,n);this.warnSizeLimit(`linked:${e}`,`More entries are attached to ${e} than the directory will list in one answer. Raise the size limit for the account ldap-rest binds as (olcLimits, or olcSizeLimit) to count them all.`);const o=r.searchEntries.slice(0,a);return[...o,LdapOrganizations.moreIndicator(`more-${e}`,"... more elements than the directory will list",o.length)]}return this.server.logger.debug(`Found ${r} linked entities for ${e}, returning ${s.length}`),r<=a?s:[...s,LdapOrganizations.moreIndicator(`more-${e}`,`... ${r-a} more ${r-a===1?"element":"elements"}`,a,r)]}async addOrganization(t,i,a){return await this.validateNewOrganization(t,i),await this.server.ldap.add(t,i,a)}async modifyOrganization(t,i,a){return await this.validateChanges(t,i),await this.server.ldap.modify(t,i,a)}async validateNewOrganization(t,i){if(!this.schema)return!0;for(const[t,a]of Object.entries(i))if(!await this._validateOneChange(t,a))throw new m(this.invalidValueMessage(t));const a=E(this.schema,i,this.server.loadedPlugins);if(a)throw new m(`Missing required field ${a}`);return!0}invalidValueMessage(t){const i=this.schema?.attributes[t],a=i?.hint||i?.items?.hint;return a?`Invalid value for field ${t}: ${a}`:`Invalid value for field ${t}`}async validateChanges(t,i){if(!this.schema)return!0;if(i.add)for(const[t,a]of Object.entries(i.add))if(!await this._validateOneChange(t,a))throw new m(this.invalidValueMessage(t));if(i.replace)for(const[t,a]of Object.entries(i.replace))if(!await this._validateOneChange(t,a))throw new m(this.invalidValueMessage(t));return!0}async _validateOneChange(t,i){if(!this.schema)return!0;const a=this.schema.attributes[t];if(!a){if(this.schema.strict)throw new m(`Field ${t} is not allowed`);return!0}if(null==i){if(a.required)throw new m(`Field ${t} is required`);return!0}if("string"===a.type&&Array.isArray(i))throw new m(`Field ${t} must be a single value`);return await A(t,a,i,t=>this.entryExists(t)),L(a,i)}async entryExists(t){try{return(await this.server.ldap.search({paged:!1,scope:"base",attributes:["dn"]},t)).searchEntries.length>0}catch{return!1}}async moveOrganization(t,i,a){let e;try{e=await this.server.ldap.search({paged:!1,scope:"base"},i)}catch(t){throw new z(`Target organization ${i} not found: ${t instanceof Error?t.message:String(t)}`)}if(!e.searchEntries||0===e.searchEntries.length)throw new z(`Target organization ${i} not found`);if(!this.isOu(e.searchEntries[0]))throw new m(`Target ${i} is not an organizational unit`);const n=`${h(t)},${i}`;let s;try{s=await this.server.ldap.search({paged:!1,scope:"base"},t)}catch(t){throw new z(`Source organization not found: ${t}`)}if(!s.searchEntries||0===s.searchEntries.length)throw new z(`Source organization ${t} not found`);if(n===t)throw new m("Cannot move organization to its current location");if(i.toLowerCase()===t.toLowerCase()||u(i,t))throw new m("Cannot move organization into itself or its descendant");try{await this.server.ldap.rename(t,n,a),this.logger.info(`Moved organization from ${t} to ${n}`)}catch(t){if(t instanceof b)throw t;throw new Error(`Failed to move organization: ${t}`)}return{newDn:n}}async deleteOrganization(t,i){return await this.server.ldap.delete(t,i)}async searchOrganisationSubnodes(t,i,a){const e=[],n=this.config.ldap_organization_max_subnodes||50;e.push(...await this.childOrganizations(t,`(&(objectClass=organizationalUnit)${w(i,"ou,description")})`,n,a));const s=this.config.ldap_top_organization,r=g(s),o=p(t),h=this.config.display_name_attribute||"displayName",c=`(&(${this.config.ldap_organization_link_attribute}=${o})${w(i,`uid,cn,mail,sn,givenName,${h}`)})`;return this.server.logger.debug(`Searching for linked entities with filter: ${c} in ${r}`),e.push(...await this.linkedEntities(r,c,n,t,a)),e}async searchOrganisationTree(t,i,a){const e=this.config.ldap_organization_max_subnodes||50,n=this.pathAttr?`ou,description,${this.pathAttr}`:"ou,description",s=`(&(objectClass=organizationalUnit)${w(i,n)})`,r=[];let o=0;try{const i=await this.server.ldap.search({paged:!0,scope:"sub",filter:s},t,a);for await(const t of i){o+=t.searchEntries.length;const i=e-r.length;i>0&&r.push(...t.searchEntries.slice(0,i))}}catch(i){const n=$(i);if(32===n)return this.server.logger.debug(`No organizations under ${t}: no such node`),[];if(4!==n)throw i;const r=(await this.server.ldap.search({paged:!1,scope:"sub",filter:s,sizeLimit:e+1},t,a)).searchEntries.slice(0,e);return this.warnSizeLimit(`search:${t}`,`A search under ${t} matched more organizations than the directory will list in one answer, so ${r.length} are returned and the rest are hidden. Raise the size limit for the account ldap-rest binds as (olcLimits, or olcSizeLimit) to search them all.`),[...r,LdapOrganizations.moreIndicator(`more-organizations-${t}`,"... more organizations than the directory will list",r.length)]}if(o<=e)return r;const h=o-e;return[...r,LdapOrganizations.moreIndicator(`more-organizations-${t}`,`... ${h} more ${1===h?"organization":"organizations"}, narrow the search`,e,o)]}getConfigApiData(){const t=this.config.api_prefix||"/api",i=this.server.loadedPlugins.static&&this.config.organization_schema?v(this.config,this.config.organization_schema):void 0;return{enabled:!0,topOrganization:this.config.ldap_top_organization||"",organizationClass:this.config.ldap_organization_class||["top","organizationalUnit"],linkAttribute:this.linkAttr||"",pathAttribute:this.pathAttr||"",pathSeparator:this.config.ldap_organization_path_separator||" / ",maxSubnodes:this.config.ldap_organization_max_subnodes||50,schema:this.schema,schemaUrl:i,endpoints:{getTop:`${t}/v1/ldap/organizations/top`,get:`${t}/v1/ldap/organizations/:dn`,getSubnodes:`${t}/v1/ldap/organizations/:dn/subnodes`,searchSubnodes:`${t}/v1/ldap/organizations/:dn/subnodes/search`,search:`${t}/v1/ldap/organizations/:dn/search`,create:`${t}/v1/ldap/organizations`,update:`${t}/v1/ldap/organizations/:dn`,move:`${t}/v1/ldap/organizations/:dn/move`,delete:`${t}/v1/ldap/organizations/:dn`}}}}LdapOrganizations.SIZE_LIMIT_WARNED_MAX=500;export{LdapOrganizations as default};
1
+ import t from"../../abstract/plugin.js";import{tryMethodData as i,jsonBody as a,tryMethod as e,wantJson as n}from"../../lib/expressFormatedResponses.js";import{asyncHandler as r,validateDnValue as s,escapeDnValue as o,getRdn as h,isDnInBranch as c,rdnValue as d,normalizeDn as l,getParentDn as g,escapeLdapFilter as p,launchHooksChained as u,isChildOf as f,substringSearchFilter as w}from"../../lib/utils.js";import{BadRequestError as m,NotFoundError as z,ConflictError as y,HttpError as b}from"../../lib/errors.js";import{extractLdapCode as $}from"../../lib/ldapCodes.js";import{loadSchemaFileAsync as _,schemaUrl as v}from"../../lib/schemaFile.js";import{assertClientMaySet as O,modifiedAttributeNames as A,missingRequiredAttribute as C,invalidValueMessage as E,checkDnValues as L,matchesPattern as k}from"../../config/schema.js";class LdapOrganizations extends t{constructor(t){if(super(t),this.name="ldapOrganizations",this.roles=["api","consistency","configurable"],this.hooks={ldapaddrequest:async([t,i,a])=>(this.isOu(i)||await this.checkDeptLink(i),this.isOu(i)&&await this.checkDeptPath(i,t),void 0!==a?[t,i,a]:[t,i]),ldapmodifyrequest:async([t,i,a,e])=>{let n,r={},s={};const checkIsOu=async()=>{if(void 0!==n)return n;if(i.replace?.objectClass)n=this.isOu({objectClass:i.replace.objectClass});else if(i.add?.objectClass)n=this.isOu({objectClass:i.add.objectClass});else{const i=await this.server.ldap.search({paged:!1,scope:"base"},t);n=i.searchEntries.length>0&&this.isOu(i.searchEntries[0])}return n};if(i.delete){const t=(Array.isArray(i.delete)?i.delete.map(String):Object.keys(i.delete)).map(t=>t.split(";")[0].toLowerCase()),a=t.includes(this.linkAttr.toLowerCase()),e=t.includes(this.pathAttr.toLowerCase());if(a||e){if(!await checkIsOu()&&a)throw new m("An organization link cannot be deleted");if(e)throw new m("An organization path cannot be deleted")}}if(i.replace&&(i.replace[this.linkAttr]&&(r={...i.replace}),i.replace[this.pathAttr]&&(s={...i.replace})),i.add&&(i.add[this.linkAttr]&&(r={...r,...i.add}),i.add[this.pathAttr]&&(s={...s,...i.add})),Object.keys(r).length>0){await checkIsOu()||await this.checkDeptLink(r)}if(Object.keys(s).length>0){await checkIsOu()&&await this.checkDeptPath(s,t)}return[t,i,a,e]},ldapdeleterequest:async([t,i])=>{const a=Array.isArray(t)?t:[t];for(const t of a)/^ou=/.test(t)&&await this.isEmptyOrganization(t);return[t,i]},ldaprenamerequest:([t,i,a])=>[t,i,a]},this.sizeLimitWarned=new Set,!this.config.ldap_top_organization)throw new Error("Missing --ldap-top-organization");if(this.pathAttr=this.config.ldap_organization_path_attribute,this.linkAttr=this.config.ldap_organization_link_attribute,t.ldap.declareObjectClasses(this.config.ldap_top_organization,this.config.ldap_organization_class||[],!0),this.config.organization_schema){const t=this.config.organization_schema;_(t,{config:this.config}).then(t=>{this.schema=t,this.logger.debug("Organization schema loaded")}).catch(i=>this.logger.error(`Failed to load organization schema from ${t}: ${i}`))}}api(t){t.get(`${this.config.api_prefix}/v1/ldap/organizations/top`,async(t,a)=>{await i(a,async()=>this.hideNeverReturn(await this.getOrganisationTop(t)))}),t.get(`${this.config.api_prefix}/v1/ldap/organizations/:dn`,async(t,a)=>{const e=decodeURIComponent(t.params.dn);await i(a,async()=>this.hideNeverReturn(await this.getOrganisationByDn(e,t)))}),t.get(`${this.config.api_prefix}/v1/ldap/organizations/:dn/subnodes`,async(t,a)=>{const e=decodeURIComponent(t.params.dn);await i(a,async()=>this.hideNeverReturn(await this.getOrganisationSubnodes(e,t)))}),t.get(`${this.config.api_prefix}/v1/ldap/organizations/:dn/subnodes/search`,r(async(t,a)=>{const e=decodeURIComponent(t.params.dn),n=t.query.q;if(!n)throw new m('query parameter "q" is required');await i(a,async()=>this.hideNeverReturn(await this.searchOrganisationSubnodes(e,n,t)))})),t.get(`${this.config.api_prefix}/v1/ldap/organizations/:dn/search`,r(async(t,a)=>{const e=decodeURIComponent(t.params.dn),n=t.query.q;if(!n)throw new m('query parameter "q" is required');await i(a,async()=>this.hideNeverReturn(await this.searchOrganisationTree(e,n,t)))})),t.post(`${this.config.api_prefix}/v1/ldap/organizations`,r(async(t,i)=>this.apiAdd(t,i))),t.put(`${this.config.api_prefix}/v1/ldap/organizations/:dn`,r(async(t,i)=>this.apiModify(t,i))),t.delete(`${this.config.api_prefix}/v1/ldap/organizations/:dn`,r(async(t,i)=>this.apiDelete(t,i))),t.post(`${this.config.api_prefix}/v1/ldap/organizations/:dn/move`,r(async(t,i)=>this.apiMove(t,i)))}async apiAdd(t,i){const n=a(t,i,"ou");if(!n)return;O(this.schema,Object.keys(n),"ou"),s(n.ou,"ou");const r=n.parentDn||this.config.ldap_top_organization,h=`ou=${o(n.ou)},${r}`,c={objectClass:this.config.ldap_organization_class,ou:n.ou,...Object.fromEntries(Object.entries(n).filter(([t])=>"ou"!==t&&"parentDn"!==t))};await e(i,this.addOrganization.bind(this),h,c,t)}async apiModify(t,i){const n=a(t,i);if(!n)return;const r=decodeURIComponent(t.params.dn);if(!r)throw new m("dn is required");O(this.schema,A(n)),await e(i,this.modifyOrganization.bind(this),r,n,t)}async apiDelete(t,i){if(!n(t,i))return;const a=decodeURIComponent(t.params.dn);if(!a)throw new m("dn is required");await e(i,this.deleteOrganization.bind(this),a,t)}async apiMove(t,e){if(!n(t,e))return;const r=a(t,e,"targetOrgDn");if(!r)return;const s=decodeURIComponent(t.params.dn);if(!s)throw new m("dn is required");const{targetOrgDn:o}=r;if(!o||"string"!=typeof o)throw new m("Missing or invalid targetOrgDn in request body");await i(e,this.moveOrganization.bind(this),s,o,t)}async checkDeptLink(t){if(t[this.linkAttr]){const i=t[this.linkAttr],a=Array.isArray(i)?i[0]:i,e=await this.server.ldap.search({paged:!1,scope:"base"},a);if(0===e.searchEntries.length)throw new z(`Organization ${a} does not exist`);if(!new RegExp(`(.*,)?${this.config.ldap_top_organization}`).test(e.searchEntries[0].dn))throw new m(`Entry ${a} isn't in top organization branch`)}}async checkDeptPath(t,i){if(t[this.pathAttr]){const a=t[this.pathAttr],e=Array.isArray(a)?a[0]:a,n=this.config.ldap_organization_path_separator||" / ",r=this.config.ldap_top_organization||"",s=i&&r&&/^ou=/i.test(h(i))&&c(i,r)?d(i):void 0,o=t.ou,u=(Array.isArray(o)?o[0]:o)||s;let f=e;if((this.isOu(t)||void 0!==s)&&u){const t=r?d(r):"";if(t&&e.startsWith(u+n)&&e.endsWith(n+t))return;if(e===u){const t=r?l(r):"";if(!i||!t||!c(i,r)||l(i)===t||l(g(i))===t)return;throw new m(`Organization path "${e}" names no parent, but ${i} is not directly under ${r}`)}if(!e.endsWith(n+u))throw new m(`Organization path must end with its own name, preceded by separator "${n}"`);f=e.slice(0,e.length-n.length-u.length)}const w=await this.server.ldap.search({paged:!1,scope:"sub",filter:`(${this.pathAttr}=${p(f)})`,attributes:["dn"]},this.config.ldap_top_organization);if(!w.searchEntries||0===w.searchEntries.length)throw new m(`Invalid organization path ${e}: no organization holds "${f}"`)}}async isEmptyOrganization(t){if((await this.server.ldap.search({paged:!1,filter:`(${this.config.ldap_organization_link_attribute}=${p(t)})`})).searchEntries.length>0)throw new y(`Organization ${t} is not empty`)}isOu(t){if(!t.objectClass)return!1;const i=[].concat(t.objectClass).map(t=>String(t).toLowerCase());return this.config.ldap_organization_class.filter(t=>"top"!==t.toLowerCase()).some(t=>i.includes(t.toLowerCase()))}async getOrganisationTop(t){if(!this.config.ldap_top_organization)throw new m("No top organization configured");const i=await this.server.ldap.search({paged:!1,scope:"base"},this.config.ldap_top_organization,t);if(1!==i.searchEntries.length)throw new z("Top organization not found");const[,a]=await u(this.registeredHooks.getOrganisationTop,[t,i.searchEntries[0]]);return a}async getOrganisationByDn(t,i){let a;try{a=await this.server.ldap.search({paged:!1,scope:"base",filter:"(objectClass=organizationalUnit)"},t,i)}catch(i){if(32===$(i))throw new z(`Organization ${t} not found`);throw i}if(1!==a.searchEntries.length)throw new z(`Organization ${t} not found`);return a.searchEntries[0]}async getOrganisationSubnodes(t,i){const a=[],e=this.config.ldap_organization_max_subnodes||50,n=i?.query?.objectClass;n&&"organizationalUnit"!==n||a.push(...await this.childOrganizations(t,"(objectClass=organizationalUnit)",e,i));const r=this.config.ldap_top_organization,s=g(r);let o=`(${this.config.ldap_organization_link_attribute}=${p(t)})`;return n&&(o=`(&${o}(objectClass=${p(n)}))`),this.server.logger.debug(`Searching for linked entities with filter: ${o} in ${s}`),a.push(...await this.linkedEntities(s,o,e,t,i)),a}warnSizeLimit(t,i){this.sizeLimitWarned.has(t)?this.server.logger.debug(i):(this.sizeLimitWarned.size<LdapOrganizations.SIZE_LIMIT_WARNED_MAX&&this.sizeLimitWarned.add(t),this.server.logger.warn(i))}static moreIndicator(t,i,a,e){return{dn:t,cn:[i],objectClass:["moreIndicator"],_isMoreIndicator:"true",...void 0===e?{}:{_totalCount:e.toString()},_displayedCount:a.toString()}}async childOrganizations(t,i,a,e){try{const a=await this.server.ldap.search({paged:!0,scope:"one",filter:i},t,e),n=[];for await(const t of a)n.push(...t.searchEntries);return this.server.logger.debug(`Found ${n.length} sub-OUs for ${t}`),n}catch(i){const a=$(i);if(32===a)return this.server.logger.debug(`No sub-OUs for ${t}: the node holds none`),[];if(4!==a)throw i}const n=(await this.server.ldap.search({paged:!1,scope:"one",filter:i,sizeLimit:a+1},t,e)).searchEntries.slice(0,a);return this.warnSizeLimit(`children:${t}`,`Organization ${t} holds more child organizations than the directory will list in one answer, so ${n.length} are returned and the rest are hidden. Raise the size limit for the account ldap-rest binds as (olcLimits, or olcSizeLimit) to list them all.`),[...n,LdapOrganizations.moreIndicator(`more-organizations-${t}`,"... more organizations than the directory will list",n.length)]}async linkedEntities(t,i,a,e,n){const r=[];let s=0;try{const e=await this.server.ldap.search({paged:!0,filter:i},t,n);for await(const t of e){s+=t.searchEntries.length;const i=a-r.length;i>0&&r.push(...t.searchEntries.slice(0,i))}}catch(r){if(4!==$(r))throw r;const s=await this.server.ldap.search({paged:!1,filter:i,sizeLimit:a+1},t,n);this.warnSizeLimit(`linked:${e}`,`More entries are attached to ${e} than the directory will list in one answer. Raise the size limit for the account ldap-rest binds as (olcLimits, or olcSizeLimit) to count them all.`);const o=s.searchEntries.slice(0,a);return[...o,LdapOrganizations.moreIndicator(`more-${e}`,"... more elements than the directory will list",o.length)]}return this.server.logger.debug(`Found ${s} linked entities for ${e}, returning ${r.length}`),s<=a?r:[...r,LdapOrganizations.moreIndicator(`more-${e}`,`... ${s-a} more ${s-a===1?"element":"elements"}`,a,s)]}async addOrganization(t,i,a){return await this.validateNewOrganization(t,i),await this.server.ldap.add(t,i,a)}async modifyOrganization(t,i,a){return await this.validateChanges(t,i),await this.server.ldap.modify(t,i,a)}async validateNewOrganization(t,i){if(!this.schema)return!0;for(const[t,a]of Object.entries(i))if(!await this._validateOneChange(t,a))throw new m(this.invalidValueMessage(t));const a=C(this.schema,i,this.server.loadedPlugins);if(a)throw new m(`Attribute "${a}" is required`);return!0}invalidValueMessage(t){return E(t,this.schema?.attributes[t])}async validateChanges(t,i){if(!this.schema)return!0;if(i.add)for(const[t,a]of Object.entries(i.add))if(!await this._validateOneChange(t,a))throw new m(this.invalidValueMessage(t));if(i.replace)for(const[t,a]of Object.entries(i.replace))if(!await this._validateOneChange(t,a))throw new m(this.invalidValueMessage(t));return!0}async _validateOneChange(t,i){if(!this.schema)return!0;const a=this.schema.attributes[t];if(!a){if(this.schema.strict)throw new m(`Attribute "${t}" is not allowed`);return!0}if(null==i||Array.isArray(i)&&0===i.length){if(a.required)throw new m(`Attribute "${t}" is required`);return!0}if("string"===a.type&&Array.isArray(i))throw new m(`Attribute "${t}" must be a single value`);return await L(t,a,i,t=>this.entryExists(t)),k(a,i)}async entryExists(t){try{return(await this.server.ldap.search({paged:!1,scope:"base",attributes:["dn"]},t)).searchEntries.length>0}catch{return!1}}async moveOrganization(t,i,a){let e;try{e=await this.server.ldap.search({paged:!1,scope:"base"},i)}catch(t){throw new z(`Target organization ${i} not found: ${t instanceof Error?t.message:String(t)}`)}if(!e.searchEntries||0===e.searchEntries.length)throw new z(`Target organization ${i} not found`);if(!this.isOu(e.searchEntries[0]))throw new m(`Target ${i} is not an organizational unit`);const n=`${h(t)},${i}`;let r;try{r=await this.server.ldap.search({paged:!1,scope:"base"},t)}catch(t){throw new z(`Source organization not found: ${t}`)}if(!r.searchEntries||0===r.searchEntries.length)throw new z(`Source organization ${t} not found`);if(n===t)throw new m("Cannot move organization to its current location");if(i.toLowerCase()===t.toLowerCase()||f(i,t))throw new m("Cannot move organization into itself or its descendant");try{await this.server.ldap.rename(t,n,a),this.logger.info(`Moved organization from ${t} to ${n}`)}catch(t){if(t instanceof b)throw t;throw new Error(`Failed to move organization: ${t}`)}return{newDn:n}}async deleteOrganization(t,i){return await this.server.ldap.delete(t,i)}async searchOrganisationSubnodes(t,i,a){const e=[],n=this.config.ldap_organization_max_subnodes||50;e.push(...await this.childOrganizations(t,`(&(objectClass=organizationalUnit)${w(i,"ou,description")})`,n,a));const r=this.config.ldap_top_organization,s=g(r),o=p(t),h=this.config.display_name_attribute||"displayName",c=`(&(${this.config.ldap_organization_link_attribute}=${o})${w(i,`uid,cn,mail,sn,givenName,${h}`)})`;return this.server.logger.debug(`Searching for linked entities with filter: ${c} in ${s}`),e.push(...await this.linkedEntities(s,c,n,t,a)),e}async searchOrganisationTree(t,i,a){const e=this.config.ldap_organization_max_subnodes||50,n=this.pathAttr?`ou,description,${this.pathAttr}`:"ou,description",r=`(&(objectClass=organizationalUnit)${w(i,n)})`,s=[];let o=0;try{const i=await this.server.ldap.search({paged:!0,scope:"sub",filter:r},t,a);for await(const t of i){o+=t.searchEntries.length;const i=e-s.length;i>0&&s.push(...t.searchEntries.slice(0,i))}}catch(i){const n=$(i);if(32===n)return this.server.logger.debug(`No organizations under ${t}: no such node`),[];if(4!==n)throw i;const s=(await this.server.ldap.search({paged:!1,scope:"sub",filter:r,sizeLimit:e+1},t,a)).searchEntries.slice(0,e);return this.warnSizeLimit(`search:${t}`,`A search under ${t} matched more organizations than the directory will list in one answer, so ${s.length} are returned and the rest are hidden. Raise the size limit for the account ldap-rest binds as (olcLimits, or olcSizeLimit) to search them all.`),[...s,LdapOrganizations.moreIndicator(`more-organizations-${t}`,"... more organizations than the directory will list",s.length)]}if(o<=e)return s;const h=o-e;return[...s,LdapOrganizations.moreIndicator(`more-organizations-${t}`,`... ${h} more ${1===h?"organization":"organizations"}, narrow the search`,e,o)]}getConfigApiData(){const t=this.config.api_prefix||"/api",i=this.server.loadedPlugins.static&&this.config.organization_schema?v(this.config,this.config.organization_schema):void 0;return{enabled:!0,topOrganization:this.config.ldap_top_organization||"",organizationClass:this.config.ldap_organization_class||["top","organizationalUnit"],linkAttribute:this.linkAttr||"",pathAttribute:this.pathAttr||"",pathSeparator:this.config.ldap_organization_path_separator||" / ",maxSubnodes:this.config.ldap_organization_max_subnodes||50,schema:this.schema,schemaUrl:i,endpoints:{getTop:`${t}/v1/ldap/organizations/top`,get:`${t}/v1/ldap/organizations/:dn`,getSubnodes:`${t}/v1/ldap/organizations/:dn/subnodes`,searchSubnodes:`${t}/v1/ldap/organizations/:dn/subnodes/search`,search:`${t}/v1/ldap/organizations/:dn/search`,create:`${t}/v1/ldap/organizations`,update:`${t}/v1/ldap/organizations/:dn`,move:`${t}/v1/ldap/organizations/:dn/move`,delete:`${t}/v1/ldap/organizations/:dn`}}}}LdapOrganizations.SIZE_LIMIT_WARNED_MAX=500;export{LdapOrganizations as default};
@@ -1 +1 @@
1
- import t from"node-fetch";import{TwakePlugin as e}from"../../abstract/twakePlugin.js";import{asyncHandler as i,escapeLdapFilter as a,isDummyMemberDn as s}from"../../lib/utils.js";import{wantJson as r}from"../../lib/expressFormatedResponses.js";class James extends e{constructor(t){super(t,"james_webadmin_url","james_webadmin_token","james_concurrency"),this.name="james",this.roles=["consistency","api"],this.dependencies={onLdapChange:"core/ldap/onChange",ldapGroups:"core/ldap/groups"},this.hooks={ldapadddone:async t=>{const[e,i]=t,a=i[this.mailAttr],s=i[this.quotaAttr],r=i[this.aliasAttr];if(!a)return;const n=Array.isArray(a)?String(a[0]):String(a);if(this.initDelay>0&&await new Promise(t=>setTimeout(t,this.initDelay)),s){const t=Array.isArray(s)?Number(s[0]):Number(s);!isNaN(t)&&t>0&&await this.callWebAdminApi("ldapadddone:quota",`${this.webadminUrl}/quota/users/${n}/size`,"PUT",e,t.toString(),{mail:n,quota:t})}if(r){const t=this.getAliases(r);await Promise.all(t.map(t=>this.callWebAdminApi("ldapadddone:alias",`${this.webadminUrl}/address/aliases/${n}/sources/${t}`,"PUT",e,null,{mail:n,alias:t})))}const l=this.getDisplayNameFromAttributes(i);l&&await this.updateJamesIdentity(e,n,l)},onLdapMailChange:async(t,e,i)=>{const a=e?Array.isArray(e)?String(e[0]):String(e):null,s=i?Array.isArray(i)?String(i[0]):String(i):null;if(a)if(s){await this.callWebAdminApi("onLdapMailChange",`${this.webadminUrl}/users/${a}/rename/${s}?action=rename&force`,"POST",t,null,{oldmail:a,newmail:s});try{const e=await this.server.ldap.search({paged:!1,scope:"base",attributes:[this.aliasAttr]},t);if(e.searchEntries&&e.searchEntries.length>0){const i=this.getAliases(e.searchEntries[0][this.aliasAttr]);i.length>0&&await Promise.all([...i.map(e=>this.callWebAdminApi("onLdapMailChange-delete",`${this.webadminUrl}/address/aliases/${a}/sources/${e}`,"DELETE",t,null,{oldmail:a,alias:e})),...i.map(e=>this.callWebAdminApi("onLdapMailChange-create",`${this.webadminUrl}/address/aliases/${s}/sources/${e}`,"PUT",t,null,{newmail:s,alias:e}))])}}catch(t){this.logger.debug("Could not fetch aliases for mail change:",t)}}else this.logger.debug(`Skipping mail rename for ${t}: newmail is empty (mail attribute was deleted, not changed)`);else this.logger.debug(`Skipping mail rename for ${t}: oldmail is empty (mail attribute was added, not changed)`)},onLdapAliasChange:async(t,e,i,a)=>{const s=i.map(t=>this.normalizeAlias(t)),r=a.map(t=>this.normalizeAlias(t)),n=s.filter(t=>!r.includes(t)),l=r.filter(t=>!s.includes(t));await Promise.all([...n.map(i=>this.callWebAdminApi("onLdapAliasChange-delete",`${this.webadminUrl}/address/aliases/${e}/sources/${i}`,"DELETE",t,null,{mail:e,alias:i,action:"delete"})),...l.map(i=>this.callWebAdminApi("onLdapAliasChange-add",`${this.webadminUrl}/address/aliases/${e}/sources/${i}`,"PUT",t,null,{mail:e,alias:i,action:"add"}))])},onLdapQuotaChange:(t,e,i,a)=>this.callWebAdminApi("onLdapQuotaChange",`${this.webadminUrl}/quota/users/${e}/size`,"PUT",t,a.toString(),{oldQuota:i,newQuota:a}),onLdapForwardChange:async(t,e,i,a)=>{const s=this.extractMailDomain(e);if(!s)return void this.logger.error(`Cannot extract domain from mail ${e} for forward management`);const r=i.filter(t=>!a.includes(t)),n=a.filter(t=>!i.includes(t));await Promise.all([...r.map(i=>this.callWebAdminApi("onLdapForwardChange-delete",`${this.webadminUrl}/address/forwards/${e}/targets/${i}`,"DELETE",t,null,{mail:e,forward:i,domain:s,action:"delete"})),...n.map(i=>this.callWebAdminApi("onLdapForwardChange-add",`${this.webadminUrl}/address/forwards/${e}/targets/${i}`,"PUT",t,null,{mail:e,forward:i,domain:s,action:"add"}))])},onLdapChange:async(t,e)=>{this.config.delegation_attribute&&e[this.config.delegation_attribute]&&await this._handleDelegationChange(t,e)},onLdapDisplayNameChange:async t=>{const e=[this.mailAttr,this.displayNameAttr,"cn","givenName","sn"],i=await this.ldapGetAttributes(t,e);if(!i)return void this.logger.warn(`Cannot update James identity: entry not found for ${t}`);const a=this.attributeToString(i[this.mailAttr]);if(!a)return void this.logger.warn(`Cannot update James identity: no mail found for ${t}`);const s=this.getDisplayNameFromAttributes(i);if(s)return this.updateJamesIdentity(t,a,s);this.logger.warn(`Cannot update James identity: no display name found for ${t}`)},ldapgroupadddone:async t=>{const[e,i]=t,a=i.mail;if(!a)return void this.logger.debug(`Group ${e} has no mail attribute, skipping James sync`);const s=this.getMailboxType(i),r=Array.isArray(a)?String(a[0]):String(a);if("teamMailbox"===s){const t=this.config.james_mailing_list_branch||[];if(t.length>0&&this.isInAllowedBranches(e,t))return void this.logger.error({plugin:this.name,event:"ldapgroupadddone",dn:e,mailboxType:"teamMailbox",error:`Team mailbox cannot be in mailing list branches: ${t.join(", ")}`});await this.createTeamMailbox(e,i,r)}else if("mailingList"===s||null===s){const t=this.config.james_mailing_list_branch||[];if(t.length>0&&!this.isInAllowedBranches(e,t))return void this.logger.error({plugin:this.name,event:"ldapgroupadddone",dn:e,mailboxType:s||"mailingList (default)",error:`Mailing list must be in allowed branches: ${t.join(", ")}`});await this.createMailingList(e,i,r)}},ldapgroupmodifydone:async t=>{const[e,i]=t;if(i.replace?.[this.mailboxTypeAttr]||i.add?.[this.mailboxTypeAttr])return void await this.handleMailboxTypeTransition(e,i);const a=await this.getGroupMail(e);if(!a)return void this.logger.debug(`Group ${e} has no mail attribute, skipping James sync`);const s="teamMailbox"===await this.getGroupMailboxType(e),r=s?this.extractMailDomain(a):null,n=[];if(i.add?.member){const t=i.add.member,l=Array.isArray(t)?t:[t],o=await this.getMemberEmails(l);n.push(...o.map(t=>s&&r?this.callWebAdminApi("ldapgroupmodifydone:teamMailbox",`${this.webadminUrl}/domains/${r}/team-mailboxes/${a}/members/${t}`,"PUT",e,null,{domain:r,groupMail:a,memberMail:t,action:"add"}):this.callWebAdminApi("ldapgroupmodifydone:mailingList",`${this.webadminUrl}/address/groups/${a}/${t}`,"PUT",e,null,{groupMail:a,memberMail:t,action:"add"})))}if(this.isAttributesList(i.delete)){const t=i.delete.member;if(t){const i=Array.isArray(t)?t:[t],l=await this.getMemberEmails(i);n.push(...l.map(t=>s&&r?this.callWebAdminApi("ldapgroupmodifydone:teamMailbox",`${this.webadminUrl}/domains/${r}/team-mailboxes/${a}/members/${t}`,"DELETE",e,null,{domain:r,groupMail:a,memberMail:t,action:"delete"}):this.callWebAdminApi("ldapgroupmodifydone:mailingList",`${this.webadminUrl}/address/groups/${a}/${t}`,"DELETE",e,null,{groupMail:a,memberMail:t,action:"delete"})))}}await Promise.all(n)},ldapgroupdeletedone:async t=>{const e=await this.getGroupMail(t);if(!e)return void this.logger.debug(`Group ${t} has no mail attribute, skipping James sync`);"teamMailbox"===await this.getGroupMailboxType(t)?await this.removeAllTeamMailboxMembers(t,e):await this.callWebAdminApi("ldapgroupdeletedone:mailingList",`${this.webadminUrl}/address/groups/${e}`,"DELETE",t,null,{groupMail:e})}},this.identityQueue=new Map,this.quotaAttr=this.config.quota_attribute,this.aliasAttr=this.config.alias_attribute||"mailAlternateAddress",this.mailboxTypeAttr=this.config.james_mailbox_type_attribute||"twakeMailboxType",this.initDelay="number"==typeof this.config.james_init_delay?this.config.james_init_delay:1e3}api(t){const e=this.config.api_prefix||"/api";t.get(`${e}/v1/users/:user/quota-usage`,i((t,e)=>this.getUserQuota(t,e))),this.logger.info(`James API registered at ${e}/v1/users/:user/quota-usage`)}async getUserQuota(e,i){if(!r(e,i))return;const s=e.params.user;try{const e=a(s),r=await this.server.ldap.search({scope:"sub",filter:`(uid=${e})`,paged:!1,attributes:[this.mailAttr]},this.config.ldap_base||""),n=r.searchEntries?.[0];if(!n)return void i.status(404).json({error:`User ${s} not found`});const l=n[this.mailAttr];if(!l)return void i.status(400).json({error:`User ${s} has no mail attribute`});const o=Array.isArray(l)?String(l[0]):String(l),m=`${this.webadminUrl}/quota/users/${o}`,d=await this.requestLimit(()=>t(m,{method:"GET",headers:this.createHeaders()}));if(!d.ok)return void(404===d.status?i.status(404).json({error:`Quota not found for user ${s}`}):i.status(502).json({error:`Failed to retrieve quota from James: ${d.statusText}`}));const h=await d.json();i.json(h)}catch(t){this.logger.error({plugin:this.name,event:"getUserQuota",username:s,error:t instanceof Error?t.message:String(t)}),i.status(500).json({error:"Internal server error while retrieving quota"})}}async handleMailboxTypeTransition(t,e){const i=await this.ldapGetAttributes(t,["mail","member",this.mailboxTypeAttr]);if(!i)return void this.logger.error({plugin:this.name,event:"handleMailboxTypeTransition",dn:t,error:"Could not fetch group attributes"});const a=i.mail;if(!a)return void this.logger.debug(`Group ${t} has no mail attribute, skipping mailbox type transition`);const s=Array.isArray(a)?String(a[0]):String(a),r=e.replace?.[this.mailboxTypeAttr]||e.add?.[this.mailboxTypeAttr],n=Array.isArray(r)?String(r[0]):String(r),l=this.getMailboxType({[this.mailboxTypeAttr]:n}),o=this.getMailboxType(i);if(this.logger.info({plugin:this.name,event:"handleMailboxTypeTransition",dn:t,mail:s,oldType:o||"none",newType:l||"none"}),o!==l)if("mailingList"===o?await this.deleteMailingList(t,s):"teamMailbox"===o&&await this.removeAllTeamMailboxMembers(t,s),"mailingList"===l){const e=this.config.james_mailing_list_branch||[];if(e.length>0&&!this.isInAllowedBranches(t,e))return void this.logger.error({plugin:this.name,event:"handleMailboxTypeTransition",dn:t,newType:l,error:`Mailing list must be in allowed branches: ${e.join(", ")}`});await this.createMailingList(t,i,s)}else if("teamMailbox"===l){const e=this.config.james_mailing_list_branch||[];if(e.length>0&&this.isInAllowedBranches(t,e))return void this.logger.error({plugin:this.name,event:"handleMailboxTypeTransition",dn:t,newType:l,error:`Team mailbox cannot be in mailing list branches: ${e.join(", ")}`});await this.createTeamMailbox(t,i,s)}}getDisplayNameFromAttributes(t){const e=this.attributeToString(t[this.displayNameAttr]);if(e)return e;const i=this.attributeToString(t.cn);if(i)return i;const a=this.attributeToString(t.givenName),s=this.attributeToString(t.sn);if(a||s){const t=[];return a&&t.push(a),s&&t.push(s),t.join(" ")}const r=this.attributeToString(t[this.mailAttr]);return r||null}async getMailFromDN(t){const e=await this.ldapGetAttributes(t,[this.mailAttr]);return e?this.attributeToString(e[this.mailAttr]):null}async getDisplayNameFromDN(t){const e=[this.displayNameAttr,"cn","givenName","sn",this.mailAttr],i=await this.ldapGetAttributes(t,e);return i?this.getDisplayNameFromAttributes(i):null}async generateSignature(t){const e=this.config.james_signature_template;if(!e)return null;const i=await this.ldapGetAttributes(t);if(!i)return null;let a=e;return a=a.replace(/\{(\w+)\}/g,(t,e)=>"string"==typeof e&&i&&"object"==typeof i&&Object.prototype.hasOwnProperty.call(i,e)&&this.attributeToString(i[e])||""),a}async updateJamesIdentity(t,e,i){const a=(this.identityQueue.get(e)??Promise.resolve()).then(()=>this.doUpdateJamesIdentity(t,e,i));this.identityQueue.set(e,a);try{await a}finally{this.identityQueue.get(e)===a&&this.identityQueue.delete(e)}}async doUpdateJamesIdentity(e,i,a){const s={plugin:this.name,event:"onLdapDisplayNameChange",result:"error",dn:e,mail:i,displayName:a};try{const r=`${this.webadminUrl}/users/${i}/identities`,n=await this.requestLimit(()=>t(`${r}?default=true`,{method:"GET",headers:this.createHeaders()}));let l;if(n.ok){const t=await n.json();l=(Array.isArray(t)?t:[t]).filter(t=>t&&t.id&&!1!==t.mayDelete).sort((t,e)=>(t.sortOrder??0)-(e.sortOrder??0))[0]}else if(404!==n.status)return void this.logger.error({...s,step:"get_default_identity",http_status:n.status,http_status_text:n.statusText});const o=await this.generateSignature(e),m=l?.id?{id:l.id,email:l.email,name:a}:{email:i,name:a,sortOrder:0};o&&(m.htmlSignature=o);const d=l?.id?"update_identity":"create_identity",h=await this.requestLimit(()=>t(l?.id?`${r}/${l.id}`:r,{method:l?.id?"PUT":"POST",headers:this.createHeaders("application/json"),body:JSON.stringify(m)}));h.ok?this.logger.info({...s,step:d,result:"success",http_status:h.status}):this.logger.error({...s,step:d,http_status:h.status,http_status_text:h.statusText})}catch(t){this.logger.error({...s,error:`${t}`})}}async deleteUserData(e,i){const a={plugin:this.name,event:"deleteUserData",mail:e,fromStep:i};try{const s=new URL(`${this.webadminUrl}/users/${e}`);s.searchParams.set("action","deleteData"),i&&s.searchParams.set("fromStep",i);const r=await this.requestLimit(()=>t(s.toString(),{method:"POST",headers:this.createHeaders()}));if(!r.ok)return this.logger.error({...a,http_status:r.status,http_status_text:r.statusText}),null;const n=await r.json();return this.logger.info({...a,http_status:r.status,taskId:n.taskId}),n}catch(t){return this.logger.error({...a,error:`${t}`}),null}}isAttributesList(t){return void 0!==t&&"object"==typeof t&&!Array.isArray(t)}getMailboxType(t){const e=t[this.mailboxTypeAttr];if(!e)return null;const i=(Array.isArray(e)?String(e[0]):String(e)).match(/^cn=([^,]+)/);if(!i)return null;const a=i[1];return"group"===a||"mailingList"===a||"teamMailbox"===a?a:null}isInAllowedBranches(t,e){return!e||0===e.length||e.some(e=>t===e||t.endsWith(","+e))}async createMailingList(t,e,i){const a=e.member||[],s=Array.isArray(a)?a:[a];this.logger.debug(`Creating mailing list ${i} in James with ${s.length} members`);const r=await this.getMemberEmails(s);await Promise.all(r.map(e=>this.callWebAdminApi("ldapgroupadddone:mailingList",`${this.webadminUrl}/address/groups/${i}/${e}`,"PUT",t,null,{groupMail:i,memberMail:e})))}async createTeamMailbox(t,e,i){const a=this.extractMailDomain(i);if(!a)return void this.logger.error({plugin:this.name,event:"createTeamMailbox",dn:t,mail:i,error:"Cannot extract domain from mail address"});const s=e.member||[],r=Array.isArray(s)?s:[s];this.logger.debug(`Creating team mailbox ${i} in James with ${r.length} members`);const n=await this.getMemberEmails(r),l=[];l.push(this.callWebAdminApi("ldapgroupadddone:teamMailbox:create",`${this.webadminUrl}/domains/${a}/team-mailboxes/${i}`,"PUT",t,null,{domain:a,teamMailbox:i}));for(const e of n)l.push(this.callWebAdminApi("ldapgroupadddone:teamMailbox:addMember",`${this.webadminUrl}/domains/${a}/team-mailboxes/${i}/members/${e}`,"PUT",t,null,{domain:a,teamMailbox:i,memberMail:e}));await Promise.all(l)}async deleteTeamMailbox(t,e){const i=this.extractMailDomain(e);i?await this.callWebAdminApi("ldapgroupdeletedone:teamMailbox",`${this.webadminUrl}/domains/${i}/team-mailboxes/${e}`,"DELETE",t,null,{domain:i,teamMailbox:e}):this.logger.error({plugin:this.name,event:"deleteTeamMailbox",dn:t,mail:e,error:"Cannot extract domain from mail address"})}async removeAllTeamMailboxMembers(t,e){const i=this.extractMailDomain(e);if(i)try{const a=await this.server.ldap.search({paged:!1,scope:"base",attributes:["member"]},t);if(a.searchEntries&&a.searchEntries.length>0){const s=a.searchEntries[0].member||[],r=Array.isArray(s)?s:[s],n=await this.getMemberEmails(r);await Promise.all(n.map(a=>this.callWebAdminApi("removeAllTeamMailboxMembers",`${this.webadminUrl}/domains/${i}/team-mailboxes/${e}/members/${a}`,"DELETE",t,null,{domain:i,teamMailbox:e,memberMail:a}))),this.logger.info({plugin:this.name,event:"removeAllTeamMailboxMembers",dn:t,teamMailbox:e,membersRemoved:n.length,message:"All members removed from team mailbox (mailbox preserved)"})}}catch(t){const i=t instanceof Error?t.message:String(t);this.logger.error(`Failed to remove members from team mailbox ${e}: ${i}`)}else this.logger.error({plugin:this.name,event:"removeAllTeamMailboxMembers",dn:t,mail:e,error:"Cannot extract domain from mail address"})}async deleteMailingList(t,e){await this.callWebAdminApi("deleteMailingList",`${this.webadminUrl}/address/groups/${e}`,"DELETE",t,null,{groupMail:e})}async getMemberEmails(t){const e=t.filter(t=>!s(t,this.config.group_dummy_user)).map(t=>this.server.ldap.queryLimit(async()=>{const e=await this.ldapGetAttributes(t,[this.mailAttr]);return e?this.attributeToString(e[this.mailAttr]):null}));return(await Promise.all(e)).filter(t=>null!==t)}async getGroupMail(t){const e=await this.ldapGetAttributes(t,["mail"]);return e?this.attributeToString(e.mail):null}async getGroupMailboxType(t){try{const e=await this.server.ldap.search({paged:!1,scope:"base",attributes:[this.mailboxTypeAttr]},t);if(e.searchEntries&&e.searchEntries.length>0)return this.getMailboxType(e.searchEntries[0])}catch(e){const i=e instanceof Error?e.message:String(e);this.logger.debug(`Could not get mailbox type for group ${t}: ${i}`)}return null}async _handleDelegationChange(t,e){const i=await this.ldapGetAttributes(t,[this.mailAttr]);if(!i)return void this.logger.warn({plugin:this.name,event:"onLdapChange",dn:t,message:"Could not find user entry to get mail attribute"});const a=i[this.mailAttr];if(!a||"string"!=typeof a)return void this.logger.warn({plugin:this.name,event:"onLdapChange",dn:t,message:"User has no mail attribute, cannot manage delegation"});const s=this.config.delegation_attribute;if(!s)return;const[r,n]=e[s]||[],l=this._normalizeToArray(r),o=this._normalizeToArray(n),m=o.filter(t=>!l.includes(t)),d=l.filter(t=>!o.includes(t)),h=m.map(t=>this.server.ldap.queryLimit(async()=>{const e=await this._getDelegateEmail(t);return{dn:t,email:e}})),u=d.map(t=>this.server.ldap.queryLimit(async()=>{const e=await this._getDelegateEmail(t);return{dn:t,email:e}})),[g,c]=await Promise.all([Promise.all(h),Promise.all(u)]),p=[];for(const{dn:e,email:i}of g)i&&p.push(this.callWebAdminApi("onLdapChange:addDelegation",`${this.webadminUrl}/users/${a}/authorizedUsers/${i}`,"PUT",t,null,{userMail:a,delegateEmail:i,delegateDN:e,action:"add"}));for(const{dn:e,email:i}of c)i&&p.push(this.callWebAdminApi("onLdapChange:removeDelegation",`${this.webadminUrl}/users/${a}/authorizedUsers/${i}`,"DELETE",t,null,{userMail:a,delegateEmail:i,delegateDN:e,action:"remove"}));await Promise.all(p)}async _getDelegateEmail(t){const e=await this.ldapGetAttributes(t,[this.mailAttr]);return e?this.attributeToString(e[this.mailAttr]):(this.logger.warn({plugin:this.name,event:"getDelegateEmail",dn:t,message:"Could not resolve delegate DN to email"}),null)}_normalizeToArray(t){return t?Array.isArray(t)?t:[t]:[]}}export{James as default};
1
+ import t from"node-fetch";import{TwakePlugin as e}from"../../abstract/twakePlugin.js";import{asyncHandler as a,escapeLdapFilter as i,isDummyMemberDn as s,normalizeDn as r}from"../../lib/utils.js";import{wantJson as n}from"../../lib/expressFormatedResponses.js";const o=[400,404],members=t=>{const e=t?.member;return void 0===e?[]:(Array.isArray(e)?e:[e]).map(String)},memberKey=t=>{try{return r(t)}catch{return t.toLowerCase()}},l=["groupofnames","groupofuniquenames","groupofurls"],isGroup=t=>(Array.isArray(t)?t:t?[t]:[]).some(t=>l.includes(String(t).toLowerCase()));class James extends e{constructor(t){super(t,"james_webadmin_url","james_webadmin_token","james_concurrency"),this.name="james",this.roles=["consistency","api"],this.dependencies={onLdapChange:"core/ldap/onChange",ldapGroups:"core/ldap/groups"},this.hooks={ldapadddone:async t=>{const[e,a]=t,i=a[this.mailAttr],s=a[this.quotaAttr],r=a[this.aliasAttr];if(!i||isGroup(a.objectClass))return;const n=Array.isArray(i)?String(i[0]):String(i);if(this.initDelay>0&&await new Promise(t=>setTimeout(t,this.initDelay)),s){const t=Array.isArray(s)?Number(s[0]):Number(s);!isNaN(t)&&t>0&&await this.callWebAdminApi("ldapadddone:quota",`${this.webadminUrl}/quota/users/${n}/size`,"PUT",e,t.toString(),{mail:n,quota:t},o)}if(r){const t=this.getAliases(r);await Promise.all(t.map(t=>this.callWebAdminApi("ldapadddone:alias",`${this.webadminUrl}/address/aliases/${n}/sources/${t}`,"PUT",e,null,{mail:n,alias:t},o)))}const l=this.getDisplayNameFromAttributes(a);l&&await this.updateJamesIdentity(e,n,l)},onLdapMailChange:async(t,e,a)=>{const i=e?Array.isArray(e)?String(e[0]):String(e):null,s=a?Array.isArray(a)?String(a[0]):String(a):null;if(i)if(s){if(!await this.isGroupDn(t)){await this.callWebAdminApi("onLdapMailChange",`${this.webadminUrl}/users/${i}/rename/${s}?action=rename&force`,"POST",t,null,{oldmail:i,newmail:s},o);try{const e=await this.server.ldap.search({paged:!1,scope:"base",attributes:[this.aliasAttr]},t);if(e.searchEntries&&e.searchEntries.length>0){const a=this.getAliases(e.searchEntries[0][this.aliasAttr]);a.length>0&&await Promise.all([...a.map(e=>this.callWebAdminApi("onLdapMailChange-delete",`${this.webadminUrl}/address/aliases/${i}/sources/${e}`,"DELETE",t,null,{oldmail:i,alias:e})),...a.map(e=>this.callWebAdminApi("onLdapMailChange-create",`${this.webadminUrl}/address/aliases/${s}/sources/${e}`,"PUT",t,null,{newmail:s,alias:e},o))])}}catch(t){this.logger.debug("Could not fetch aliases for mail change:",t)}}}else this.logger.debug(`Skipping mail rename for ${t}: newmail is empty (mail attribute was deleted, not changed)`);else this.logger.debug(`Skipping mail rename for ${t}: oldmail is empty (mail attribute was added, not changed)`)},onLdapAliasChange:async(t,e,a,i)=>{if(await this.isGroupDn(t))return;const s=a.map(t=>this.normalizeAlias(t)),r=i.map(t=>this.normalizeAlias(t)),n=s.filter(t=>!r.includes(t)),l=r.filter(t=>!s.includes(t));await Promise.all([...n.map(a=>this.callWebAdminApi("onLdapAliasChange-delete",`${this.webadminUrl}/address/aliases/${e}/sources/${a}`,"DELETE",t,null,{mail:e,alias:a,action:"delete"})),...l.map(a=>this.callWebAdminApi("onLdapAliasChange-add",`${this.webadminUrl}/address/aliases/${e}/sources/${a}`,"PUT",t,null,{mail:e,alias:a,action:"add"},o))])},onLdapQuotaChange:async(t,e,a,i)=>{if(!await this.isGroupDn(t))return this.callWebAdminApi("onLdapQuotaChange",`${this.webadminUrl}/quota/users/${e}/size`,"PUT",t,i.toString(),{oldQuota:a,newQuota:i},o)},onLdapForwardChange:async(t,e,a,i)=>{if(await this.isGroupDn(t))return;const s=this.extractMailDomain(e);if(!s)return void this.logger.error(`Cannot extract domain from mail ${e} for forward management`);const r=a.filter(t=>!i.includes(t)),n=i.filter(t=>!a.includes(t));await Promise.all([...r.map(a=>this.callWebAdminApi("onLdapForwardChange-delete",`${this.webadminUrl}/address/forwards/${e}/targets/${a}`,"DELETE",t,null,{mail:e,forward:a,domain:s,action:"delete"})),...n.map(a=>this.callWebAdminApi("onLdapForwardChange-add",`${this.webadminUrl}/address/forwards/${e}/targets/${a}`,"PUT",t,null,{mail:e,forward:a,domain:s,action:"add"},o))])},onLdapChange:async(t,e)=>{this.config.delegation_attribute&&e[this.config.delegation_attribute]&&!await this.isGroupDn(t)&&await this._handleDelegationChange(t,e)},onLdapDisplayNameChange:async t=>{const e=[this.mailAttr,this.displayNameAttr,"cn","givenName","sn","objectClass"],a=await this.ldapGetAttributes(t,e);if(!a)return void this.logger.warn(`Cannot update James identity: entry not found for ${t}`);if(isGroup(a.objectClass))return;const i=this.attributeToString(a[this.mailAttr]);if(!i)return void this.logger.warn(`Cannot update James identity: no mail found for ${t}`);const s=this.getDisplayNameFromAttributes(a);if(s)return this.updateJamesIdentity(t,i,s);this.logger.warn(`Cannot update James identity: no display name found for ${t}`)},onLdapEntryChange:(t,e,a)=>this.followTeamMailbox(e,a)},this.teamMailboxQueue=new Map,this.identityQueue=new Map,this.quotaAttr=this.config.quota_attribute,this.aliasAttr=this.config.alias_attribute||"mailAlternateAddress",this.mailboxTypeAttr=this.config.james_mailbox_type_attribute||"twakeMailboxType",this.initDelay="number"==typeof this.config.james_init_delay?this.config.james_init_delay:1e3,this.retryAttempts="number"==typeof this.config.james_retry_attempts?Math.max(1,this.config.james_retry_attempts):5,this.retryDelay="number"==typeof this.config.james_retry_delay?Math.max(0,this.config.james_retry_delay):1e3}api(t){const e=this.config.api_prefix||"/api";t.get(`${e}/v1/users/:user/quota-usage`,a((t,e)=>this.getUserQuota(t,e))),this.logger.info(`James API registered at ${e}/v1/users/:user/quota-usage`)}async getUserQuota(e,a){if(!n(e,a))return;const s=e.params.user;try{const e=i(s),r=await this.server.ldap.search({scope:"sub",filter:`(uid=${e})`,paged:!1,attributes:[this.mailAttr]},this.config.ldap_base||""),n=r.searchEntries?.[0];if(!n)return void a.status(404).json({error:`User ${s} not found`});const o=n[this.mailAttr];if(!o)return void a.status(400).json({error:`User ${s} has no mail attribute`});const l=Array.isArray(o)?String(o[0]):String(o),u=`${this.webadminUrl}/quota/users/${l}`,m=await this.requestLimit(()=>t(u,{method:"GET",headers:this.createHeaders()}));if(!m.ok)return void(404===m.status?a.status(404).json({error:`Quota not found for user ${s}`}):a.status(502).json({error:`Failed to retrieve quota from James: ${m.statusText}`}));const d=await m.json();a.json(d)}catch(t){this.logger.error({plugin:this.name,event:"getUserQuota",username:s,error:t instanceof Error?t.message:String(t)}),a.status(500).json({error:"Internal server error while retrieving quota"})}}teamMailboxOf(t){if(!t||"teamMailbox"!==this.getMailboxType(t))return null;const e=this.attributeToString(t.mail);if(!e)return null;const a=String(t.dn);return{mail:e,refused:(this.config.james_mailing_list_branch||[]).some(t=>a===t||a.endsWith(","+t))}}async followTeamMailbox(t,e){const a=this.teamMailboxOf(t),i=this.teamMailboxOf(e);if(!a&&!i)return;const s=String((e??t).dn);i?.refused&&!a?.refused&&this.logger.error({plugin:this.name,event:"teamMailbox",dn:s,error:`Team mailbox cannot be in mailing list branches: ${(this.config.james_mailing_list_branch||[]).join(", ")}`});const r=a&&!a.refused?a.mail:null,n=i&&!i.refused?i.mail:null;if(!r&&!n)return;const o=memberKey(s),follow=async()=>{if(r&&n&&r.toLowerCase()===n.toLowerCase()){const a=new Map(members(t).map(t=>[memberKey(t),t])),i=[];for(const t of members(e))a.delete(memberKey(t))||i.push(t);return void await Promise.all([this.teamMailboxMembers(s,n,"PUT",i),this.teamMailboxMembers(s,n,"DELETE",[...a.values()])])}r&&await this.teamMailboxMembers(s,r,"DELETE",members(t)),n&&await this.createTeamMailbox(s,e,n)},l=(this.teamMailboxQueue.get(o)??Promise.resolve()).then(follow,follow);this.teamMailboxQueue.set(o,l);try{await l}finally{this.teamMailboxQueue.get(o)===l&&this.teamMailboxQueue.delete(o)}}async teamMailboxMembers(t,e,a,i){const s=this.extractMailDomain(e);if(!s||!i.length)return;const r=await this.getMemberEmails(i);await Promise.all(r.map(i=>this.callWebAdminApi("teamMailbox:"+("PUT"===a?"addMember":"removeMember"),`${this.webadminUrl}/domains/${s}/team-mailboxes/${e}/members/${i}`,a,t,null,{domain:s,teamMailbox:e,memberMail:i})))}getDisplayNameFromAttributes(t){const e=this.attributeToString(t[this.displayNameAttr]);if(e)return e;const a=this.attributeToString(t.cn);if(a)return a;const i=this.attributeToString(t.givenName),s=this.attributeToString(t.sn);if(i||s){const t=[];return i&&t.push(i),s&&t.push(s),t.join(" ")}const r=this.attributeToString(t[this.mailAttr]);return r||null}async isGroupDn(t){const e=await this.ldapGetAttributes(t,["objectClass"]);return isGroup(e?.objectClass)}async getMailFromDN(t){const e=await this.ldapGetAttributes(t,[this.mailAttr]);return e?this.attributeToString(e[this.mailAttr]):null}async getDisplayNameFromDN(t){const e=[this.displayNameAttr,"cn","givenName","sn",this.mailAttr],a=await this.ldapGetAttributes(t,e);return a?this.getDisplayNameFromAttributes(a):null}async generateSignature(t){const e=this.config.james_signature_template;if(!e)return null;const a=await this.ldapGetAttributes(t);if(!a)return null;let i=e;return i=i.replace(/\{(\w+)\}/g,(t,e)=>"string"==typeof e&&a&&"object"==typeof a&&Object.prototype.hasOwnProperty.call(a,e)&&this.attributeToString(a[e])||""),i}async updateJamesIdentity(t,e,a){const i=(this.identityQueue.get(e)??Promise.resolve()).then(()=>this.doUpdateJamesIdentity(t,e,a));this.identityQueue.set(e,i);try{await i}finally{this.identityQueue.get(e)===i&&this.identityQueue.delete(e)}}async doUpdateJamesIdentity(e,a,i){const s={plugin:this.name,event:"onLdapDisplayNameChange",result:"error",dn:e,mail:a,displayName:i};for(let r=1;;r++){try{const n=`${this.webadminUrl}/users/${a}/identities`,l=await this.requestLimit(()=>t(`${n}?default=true`,{method:"GET",headers:this.createHeaders()}));let u;if(l.ok){const t=await l.json();u=(Array.isArray(t)?t:[t]).filter(t=>t&&t.id&&!1!==t.mayDelete).sort((t,e)=>(t.sortOrder??0)-(e.sortOrder??0))[0]}else if(404!==l.status){const t="get_default_identity";if(this.shouldRetry(l.status,o,r)){await this.warnAndWait(s,t,r,l);continue}return void this.logger.error({...s,step:t,...r>1?{attempts:r}:{},http_status:l.status,http_status_text:l.statusText})}const m=await this.generateSignature(e),d=u?.id?{id:u.id,email:u.email,name:i}:{email:a,name:i,sortOrder:0};m&&(d.htmlSignature=m);const h=u?.id?"update_identity":"create_identity",c=await this.requestLimit(()=>t(u?.id?`${n}/${u.id}`:n,{method:u?.id?"PUT":"POST",headers:this.createHeaders("application/json"),body:JSON.stringify(d)}));if(c.ok)this.logger.info({...s,step:h,result:"success",http_status:c.status});else{if(this.shouldRetry(c.status,o,r)){await this.warnAndWait(s,h,r,c);continue}this.logger.error({...s,step:h,...r>1?{attempts:r}:{},http_status:c.status,http_status_text:c.statusText})}}catch(t){this.logger.error({...s,error:`${t}`})}return}}async warnAndWait(t,e,a,i){this.logger.warn({...t,step:e,result:"retry",attempt:a,http_status:i.status,http_status_text:i.statusText}),await this.waitBeforeRetry(a)}async deleteUserData(e,a){const i={plugin:this.name,event:"deleteUserData",mail:e,fromStep:a};try{const s=new URL(`${this.webadminUrl}/users/${e}`);s.searchParams.set("action","deleteData"),a&&s.searchParams.set("fromStep",a);const r=await this.requestLimit(()=>t(s.toString(),{method:"POST",headers:this.createHeaders()}));if(!r.ok)return this.logger.error({...i,http_status:r.status,http_status_text:r.statusText}),null;const n=await r.json();return this.logger.info({...i,http_status:r.status,taskId:n.taskId}),n}catch(t){return this.logger.error({...i,error:`${t}`}),null}}getMailboxType(t){const e=t[this.mailboxTypeAttr];if(!e)return null;const a=(Array.isArray(e)?String(e[0]):String(e)).match(/^cn=([^,]+)/);if(!a)return null;const i=a[1];return"group"===i||"mailingList"===i||"teamMailbox"===i?i:null}async createTeamMailbox(t,e,a){const i=this.extractMailDomain(a);i?(await this.callWebAdminApi("teamMailbox:create",`${this.webadminUrl}/domains/${i}/team-mailboxes/${a}`,"PUT",t,null,{domain:i,teamMailbox:a}),await this.teamMailboxMembers(t,a,"PUT",members(e))):this.logger.error({plugin:this.name,event:"createTeamMailbox",dn:t,mail:a,error:"Cannot extract domain from mail address"})}async getMemberEmails(t){const e=t.filter(t=>!s(t,this.config.group_dummy_user)).map(t=>this.server.ldap.queryLimit(async()=>{const e=await this.ldapGetAttributes(t,[this.mailAttr]);return e?this.attributeToString(e[this.mailAttr]):null}));return(await Promise.all(e)).filter(t=>null!==t)}async getGroupMail(t){const e=await this.ldapGetAttributes(t,["mail"]);return e?this.attributeToString(e.mail):null}async getGroupMailboxType(t){try{const e=await this.server.ldap.search({paged:!1,scope:"base",attributes:[this.mailboxTypeAttr]},t);if(e.searchEntries&&e.searchEntries.length>0)return this.getMailboxType(e.searchEntries[0])}catch(e){const a=e instanceof Error?e.message:String(e);this.logger.debug(`Could not get mailbox type for group ${t}: ${a}`)}return null}async _handleDelegationChange(t,e){const a=await this.ldapGetAttributes(t,[this.mailAttr]);if(!a)return void this.logger.warn({plugin:this.name,event:"onLdapChange",dn:t,message:"Could not find user entry to get mail attribute"});const i=a[this.mailAttr];if(!i||"string"!=typeof i)return void this.logger.warn({plugin:this.name,event:"onLdapChange",dn:t,message:"User has no mail attribute, cannot manage delegation"});const s=this.config.delegation_attribute;if(!s)return;const[r,n]=e[s]||[],l=this._normalizeToArray(r),u=this._normalizeToArray(n),m=u.filter(t=>!l.includes(t)),d=l.filter(t=>!u.includes(t)),h=m.map(t=>this.server.ldap.queryLimit(async()=>{const e=await this._getDelegateEmail(t);return{dn:t,email:e}})),c=d.map(t=>this.server.ldap.queryLimit(async()=>{const e=await this._getDelegateEmail(t);return{dn:t,email:e}})),[g,p]=await Promise.all([Promise.all(h),Promise.all(c)]),b=[];for(const{dn:e,email:a}of g)a&&b.push(this.callWebAdminApi("onLdapChange:addDelegation",`${this.webadminUrl}/users/${i}/authorizedUsers/${a}`,"PUT",t,null,{userMail:i,delegateEmail:a,delegateDN:e,action:"add"},o));for(const{dn:e,email:a}of p)a&&b.push(this.callWebAdminApi("onLdapChange:removeDelegation",`${this.webadminUrl}/users/${i}/authorizedUsers/${a}`,"DELETE",t,null,{userMail:i,delegateEmail:a,delegateDN:e,action:"remove"}));await Promise.all(b)}async _getDelegateEmail(t){const e=await this.ldapGetAttributes(t,[this.mailAttr]);return e?this.attributeToString(e[this.mailAttr]):(this.logger.warn({plugin:this.name,event:"getDelegateEmail",dn:t,message:"Could not resolve delegate DN to email"}),null)}_normalizeToArray(t){return t?Array.isArray(t)?t:[t]:[]}}export{James as default};
@@ -239,6 +239,15 @@ export default abstract class LdapFlat extends DmPlugin {
239
239
  * @returns lowercased attribute names
240
240
  */
241
241
  protected hiddenAttributes(): Set<string>;
242
+ /**
243
+ * Attributes to ask for when reading one entry. `*` brings the user
244
+ * attributes only: an operational one the schema declares, such as
245
+ * `memberOf`, comes back only when named. A name the directory does not
246
+ * define is ignored by it.
247
+ *
248
+ * @returns attribute list
249
+ */
250
+ protected entryAttributes(): string[];
242
251
  /**
243
252
  * Remove the `neverReturn` attributes from an entry about to be serialised.
244
253
  * LDAP attribute names are case-insensitive and may carry options
@@ -294,17 +303,6 @@ export default abstract class LdapFlat extends DmPlugin {
294
303
  * @returns true when the DN is taken
295
304
  */
296
305
  protected rdnExists(value: string): Promise<boolean>;
297
- /**
298
- * Build the rejection message for a value that failed its schema `test`.
299
- *
300
- * When the schema carries a `hint`, the message says what a valid value
301
- * looks like instead of only that this one was not. The hint lives next to
302
- * the pattern so the two cannot drift apart, and so a client can show it
303
- * under the field *before* the user gets it wrong.
304
- *
305
- * @param field attribute name
306
- * @returns message for a `BadRequestError`
307
- */
308
306
  protected invalidValueMessage(field: string): string;
309
307
  /**
310
308
  * API routes
@@ -12,6 +12,8 @@ export declare abstract class TwakePlugin extends DmPlugin {
12
12
  protected webadminUrl: string;
13
13
  protected webadminToken: string;
14
14
  protected requestLimit: ReturnType<typeof pLimit>;
15
+ protected retryAttempts: number;
16
+ protected retryDelay: number;
15
17
  protected mailAttr: string;
16
18
  protected displayNameAttr: string;
17
19
  /**
@@ -39,8 +41,23 @@ export declare abstract class TwakePlugin extends DmPlugin {
39
41
  * @param dn LDAP DN for logging
40
42
  * @param body Request body (string or null)
41
43
  * @param fields Additional fields for logging
44
+ * @param retryOn HTTP statuses worth another attempt, up to
45
+ * `retryAttempts` in all; none by default
42
46
  */
43
- protected callWebAdminApi(hookname: string, url: string, method: string, dn: string, body: string | null, fields: object): Promise<void>;
47
+ protected callWebAdminApi(hookname: string, url: string, method: string, dn: string, body: string | null, fields: object, retryOn?: readonly number[]): Promise<void>;
48
+ /**
49
+ * Whether a failed call is worth another attempt
50
+ * @param statusCode HTTP status code of the failure
51
+ * @param retryOn HTTP statuses the call retries on
52
+ * @param attempt Number of the attempt that failed, from 1
53
+ */
54
+ protected shouldRetry(statusCode: number, retryOn: readonly number[], attempt: number): boolean;
55
+ /**
56
+ * Wait before the attempt following `attempt`: `retryDelay` times the
57
+ * number of attempts made, so that the waits grow linearly
58
+ * @param attempt Number of the attempt that failed, from 1
59
+ */
60
+ protected waitBeforeRetry(attempt: number): Promise<void>;
44
61
  /**
45
62
  * Override this method to customize error handling for specific status codes
46
63
  * @param statusCode HTTP status code
@@ -61,6 +61,15 @@ export declare class DM {
61
61
  */
62
62
  private _composed;
63
63
  private _errorMiddleware?;
64
+ /**
65
+ * The loads of `--plugin` entries without overrides, keyed by resolved
66
+ * module and name. Regular plugins load in parallel and load their own
67
+ * dependencies, so one module is often asked for twice: in the
68
+ * configuration and by a plugin depending on it, or listed twice. The
69
+ * second request waits for the first rather than building an instance
70
+ * whose name is taken. A Map, so that `withConfig` views share it.
71
+ */
72
+ private readonly pluginLoads;
64
73
  constructor();
65
74
  /**
66
75
  * The Express router stack, whatever the Express version calls it: 5
@@ -201,6 +210,7 @@ export declare class DM {
201
210
  */
202
211
  private resolveModule;
203
212
  loadPlugin(pluginName: string): Promise<boolean>;
213
+ private importPlugin;
204
214
  /**
205
215
  * Refuse a plugin whose arrival makes the authorization composition
206
216
  * ambiguous, before any of it is registered.
@@ -1,6 +1,7 @@
1
1
  /**
2
2
  * TypeScript types for LDAP Group Editor
3
3
  */
4
+ import type { LocalizedText } from '../shared/types';
4
5
  export interface GroupEditorOptions {
5
6
  containerId: string;
6
7
  apiBaseUrl?: string;
@@ -45,7 +46,7 @@ export interface SchemaAttribute {
45
46
  type: string;
46
47
  branch?: string[];
47
48
  };
48
- group?: string;
49
+ group?: LocalizedText;
49
50
  }
50
51
  export interface LdapGroup {
51
52
  dn: string;
@@ -1,6 +1,7 @@
1
1
  /**
2
2
  * LDAP Resource Editor - Generic Type Definitions
3
3
  */
4
+ import type { LocalizedText } from '../shared/types';
4
5
  export type ResourceType = 'users' | 'groups' | 'organizations';
5
6
  export type NavigationType = 'flat' | 'tree';
6
7
  export interface EditorOptions {
@@ -19,13 +20,13 @@ export interface SchemaAttribute {
19
20
  type: string;
20
21
  required?: boolean;
21
22
  fixed?: boolean;
22
- role?: string;
23
+ role?: string | string[];
23
24
  test?: string;
24
25
  branch?: string[];
25
26
  items?: SchemaAttribute;
26
27
  default?: unknown;
27
28
  ui?: 'select' | 'search';
28
- group?: string;
29
+ group?: LocalizedText;
29
30
  }
30
31
  export interface Schema {
31
32
  entity: {
@@ -1,6 +1,7 @@
1
1
  /**
2
2
  * TypeScript types for LDAP Unit Editor
3
3
  */
4
+ import type { LocalizedText } from '../shared/types';
4
5
  export interface UnitEditorOptions {
5
6
  containerId: string;
6
7
  apiBaseUrl?: string;
@@ -45,7 +46,7 @@ export interface SchemaAttribute {
45
46
  type: string;
46
47
  branch?: string[];
47
48
  };
48
- group?: string;
49
+ group?: LocalizedText;
49
50
  }
50
51
  export interface LdapUnit {
51
52
  dn: string;
@@ -1,6 +1,7 @@
1
1
  /**
2
2
  * LDAP User Editor - Type definitions
3
3
  */
4
+ import type { LocalizedText } from '../shared/types';
4
5
  export interface EditorOptions {
5
6
  containerId: string;
6
7
  apiBaseUrl?: string;
@@ -15,13 +16,13 @@ export interface SchemaAttribute {
15
16
  type: string;
16
17
  required?: boolean;
17
18
  fixed?: boolean;
18
- role?: string;
19
+ role?: string | string[];
19
20
  test?: string;
20
21
  branch?: string[];
21
22
  items?: SchemaAttribute;
22
23
  default?: unknown;
23
24
  ui?: 'select' | 'search';
24
- group?: string;
25
+ group?: LocalizedText;
25
26
  }
26
27
  export interface Schema {
27
28
  entity: {
@@ -2,6 +2,11 @@
2
2
  * Shared TypeScript types for browser components
3
3
  * @module browser/shared/types
4
4
  */
5
+ /**
6
+ * Text of a schema in one language (a plain string) or several (a map keyed
7
+ * by language tag), see `localizedText`
8
+ */
9
+ export type LocalizedText = string | Record<string, string>;
5
10
  export interface EditorConfig {
6
11
  ldapBase?: string;
7
12
  features?: {
@@ -27,13 +32,14 @@ export interface SchemaAttribute {
27
32
  type: 'string' | 'number' | 'integer' | 'array' | 'pointer';
28
33
  required?: boolean;
29
34
  fixed?: boolean;
35
+ role?: string | string[];
30
36
  default?: unknown;
31
37
  branch?: string[];
32
38
  items?: {
33
39
  type: string;
34
40
  branch?: string[];
35
41
  };
36
- group?: string;
42
+ group?: LocalizedText;
37
43
  }
38
44
  export interface PointerOption {
39
45
  dn: string;
@@ -2,7 +2,7 @@
2
2
  * Schema utility functions
3
3
  * @module browser/shared/utils/schema
4
4
  */
5
- import type { PointerOption } from '../types';
5
+ import type { LocalizedText, PointerOption, SchemaAttribute } from '../types';
6
6
  /**
7
7
  * Load pointer options from API
8
8
  */
@@ -27,3 +27,22 @@ export declare function getOrganizationPath(orgDn: string, apiBaseUrl: string):
27
27
  * Load schema from URL or object
28
28
  */
29
29
  export declare function loadSchema<T = unknown>(schemaSource: string | T): Promise<T>;
30
+ /**
31
+ * Tell whether an attribute carries a role: a schema names one, or several.
32
+ *
33
+ * @param attr attribute definition
34
+ * @param role role to look for
35
+ * @returns true when the attribute declares that role
36
+ */
37
+ export declare function hasRole(attr: Pick<SchemaAttribute, 'role'> | undefined, role: string): boolean;
38
+ /**
39
+ * Pick the text of a {@link LocalizedText} for a language: the exact tag,
40
+ * then its primary language, then English, then whatever the map holds.
41
+ *
42
+ * The editors are written in English, so English is the default.
43
+ *
44
+ * @param text plain string or map keyed by language tag
45
+ * @param language tag to look for
46
+ * @returns the text, or undefined when there is none
47
+ */
48
+ export declare function localizedText(text: LocalizedText | undefined, language?: string): string | undefined;
@@ -166,6 +166,8 @@ export interface Config {
166
166
  ldap_concurrency?: number;
167
167
  james_concurrency?: number;
168
168
  james_init_delay?: number;
169
+ james_retry_attempts?: number;
170
+ james_retry_delay?: number;
169
171
  james_mailing_list_branch?: string[];
170
172
  james_mailbox_type_attribute?: string;
171
173
  twake_drive_webadmin_url?: string;
@@ -49,6 +49,29 @@ export type SchemaRole = string;
49
49
  * rather than in a catalogue shipped with the code.
50
50
  */
51
51
  export type LocalizedText = string | Record<string, string>;
52
+ /**
53
+ * Pick the text of a {@link LocalizedText} for a language: the exact tag,
54
+ * then its primary language, then English, then whatever the map holds.
55
+ *
56
+ * @param text plain string or map keyed by language tag
57
+ * @param language tag to look for, English when omitted
58
+ * @returns the text, or undefined when there is none
59
+ */
60
+ export declare function localizedText(text: LocalizedText | undefined, language?: string): string | undefined;
61
+ /**
62
+ * Refusal of a value that failed its schema `test`, for a `BadRequestError`.
63
+ *
64
+ * When the schema carries a `hint`, the message says what a valid value
65
+ * looks like instead of only that this one was not. The hint lives next to
66
+ * the pattern so the two cannot drift apart, and so a client can show it
67
+ * under the field *before* the user gets it wrong. Its English text is
68
+ * quoted, the language of every other API message.
69
+ *
70
+ * @param name attribute name
71
+ * @param attr its schema definition
72
+ * @returns the message
73
+ */
74
+ export declare function invalidValueMessage(name: string, attr: SchemaAttribute | undefined): string;
52
75
  /** Roles the core itself looks for. See {@link SchemaRole}. */
53
76
  export declare const CORE_ROLES: readonly ["identifier", "displayName", "primaryEmail", "emailAliases", "emailQuota", "organizationLink", "organizationPath", "members", "owners", "accountStatus", "password", "passwordReset", "accountExpiry", "domainLink", "domainName"];
54
77
  /**
@@ -114,7 +137,7 @@ export interface SchemaAttribute {
114
137
  items?: {
115
138
  type: string;
116
139
  test?: string | RegExp;
117
- hint?: string;
140
+ hint?: LocalizedText;
118
141
  branch?: string[];
119
142
  };
120
143
  default?: AttributeValue;
@@ -126,7 +149,7 @@ export interface SchemaAttribute {
126
149
  * schema so the pattern and its explanation cannot drift apart, and so a
127
150
  * client never has to hardcode either.
128
151
  */
129
- hint?: string;
152
+ hint?: LocalizedText;
130
153
  branch?: string[];
131
154
  fixed?: boolean;
132
155
  /** Semantic role(s) — see {@link SchemaRole} */
@@ -209,8 +232,8 @@ export interface SchemaAttribute {
209
232
  * under `example.org`. Off by default: the domain must match exactly.
210
233
  */
211
234
  allowSubdomains?: boolean;
212
- /** Grouping hint for a form, e.g. `Mailbox Settings` */
213
- group?: string;
235
+ /** Section of a form the attribute belongs to, e.g. `Mailbox settings` */
236
+ group?: LocalizedText;
214
237
  /** Name a client shows for this attribute, in one or several languages */
215
238
  label?: LocalizedText;
216
239
  }
@@ -127,7 +127,9 @@ export interface Hooks {
127
127
  * `onLdapEntryChange` gives the entry as the directory held it before and
128
128
  * after an add, a modify, a rename or a delete; the other hooks are derived
129
129
  * from it. `before` is null on an add, `after` on a delete, and on a rename
130
- * their `dn` differ. A write that changed nothing fires none of them.
130
+ * their `dn` differ. A write that changed nothing fires none of them. A
131
+ * delete fires `onLdapEntryChange`, `onLdapChange` and `onLdapMailChange`
132
+ * only, and the hooks given a `mail` fire only while the entry has one.
131
133
  */
132
134
  onLdapEntryChange?: (dn: string, before: Entry | null, after: Entry | null, context: ChangeContext) => MaybePromise<void>;
133
135
  onLdapChange?: (dn: string, changes: ChangesToNotify) => MaybePromise<void>;
@@ -231,14 +231,6 @@ export default class LdapOrganizations extends DmPlugin {
231
231
  addOrganization(dn: string, entry: AttributesList, req?: Request): Promise<boolean>;
232
232
  modifyOrganization(dn: string, changes: ModifyRequest, req?: Request): Promise<boolean>;
233
233
  validateNewOrganization(dn: string, entry: AttributesList): Promise<boolean>;
234
- /**
235
- * Build the rejection message for a value that failed its schema `test`,
236
- * quoting the `hint` when the schema carries one so the answer says what a
237
- * valid value looks like.
238
- *
239
- * @param field attribute name
240
- * @returns message for a `BadRequestError`
241
- */
242
234
  invalidValueMessage(field: string): string;
243
235
  validateChanges(dn: string, changes: ModifyRequest): Promise<boolean>;
244
236
  _validateOneChange(field: string, value: AttributeValue | null): Promise<boolean>;
@@ -5,53 +5,6 @@ import type { DM } from '../../bin';
5
5
  import type { AttributeValue } from '../../lib/ldapActions';
6
6
  import { Hooks } from '../../hooks';
7
7
  import type { ChangesToNotify } from '../ldap/onChange';
8
- /**
9
- * OpenAPI schemas specific to the James integration.
10
- *
11
- * @openapi-component
12
- * JamesQuotaUsage:
13
- * type: object
14
- * description: |
15
- * Quota information for a mailbox user as returned by the James
16
- * WebAdmin API (`GET /quota/users/{mail}`). Contains separate
17
- * counters for message count and storage size.
18
- * properties:
19
- * count:
20
- * type: object
21
- * description: Message-count quota.
22
- * properties:
23
- * used:
24
- * type: integer
25
- * description: Number of messages currently stored.
26
- * example: 142
27
- * max:
28
- * type: integer
29
- * nullable: true
30
- * description: |
31
- * Maximum number of messages allowed. `null` means unlimited.
32
- * example: 10000
33
- * size:
34
- * type: object
35
- * description: Storage-size quota (bytes).
36
- * properties:
37
- * used:
38
- * type: integer
39
- * description: Bytes currently used.
40
- * example: 524288000
41
- * max:
42
- * type: integer
43
- * nullable: true
44
- * description: |
45
- * Maximum bytes allowed. `null` means unlimited.
46
- * example: 2147483648
47
- * example:
48
- * count:
49
- * used: 142
50
- * max: 10000
51
- * size:
52
- * used: 524288000
53
- * max: 2147483648
54
- */
55
8
  export default class James extends TwakePlugin {
56
9
  name: string;
57
10
  roles: Role[];
@@ -74,14 +27,23 @@ export default class James extends TwakePlugin {
74
27
  private getUserQuota;
75
28
  hooks: Hooks;
76
29
  /**
77
- * Handle mailbox type transitions when twakeMailboxType changes
30
+ * The address of the team mailbox an entry stands for, and whether it is
31
+ * refused for lying in a mailing list branch.
78
32
  */
79
- private handleMailboxTypeTransition;
33
+ private teamMailboxOf;
34
+ /**
35
+ * Bring James from the team mailbox an entry was to the one it is. A
36
+ * mailbox the entry no longer stands for is kept, emptied of its members.
37
+ */
38
+ private followTeamMailbox;
39
+ private teamMailboxQueue;
40
+ private teamMailboxMembers;
80
41
  /**
81
42
  * Extract display name from LDAP attributes
82
43
  * Fallback logic: displayName → cn → givenName+sn → mail
83
44
  */
84
45
  private getDisplayNameFromAttributes;
46
+ private isGroupDn;
85
47
  getMailFromDN(dn: string): Promise<string | null>;
86
48
  getDisplayNameFromDN(dn: string): Promise<string | null>;
87
49
  generateSignature(dn: string): Promise<string | null>;
@@ -104,6 +66,7 @@ export default class James extends TwakePlugin {
104
66
  updateJamesIdentity(dn: string, mail: string, displayName: string): Promise<void>;
105
67
  private identityQueue;
106
68
  private doUpdateJamesIdentity;
69
+ private warnAndWait;
107
70
  /**
108
71
  * Delete user data via James WebAdmin API
109
72
  * Calls POST /users/{mail}?action=deleteData
@@ -112,40 +75,15 @@ export default class James extends TwakePlugin {
112
75
  deleteUserData(mail: string, fromStep?: string): Promise<{
113
76
  taskId: string;
114
77
  } | null>;
115
- /**
116
- * Type guard to check if delete operation is an AttributesList
117
- */
118
- private isAttributesList;
119
78
  /**
120
79
  * Extract mailbox type from group attributes
121
80
  * Returns: 'group', 'mailingList', 'teamMailbox', or null if not set
122
81
  */
123
82
  private getMailboxType;
124
- /**
125
- * Validate that a DN is within one of the allowed branches
126
- */
127
- private isInAllowedBranches;
128
- /**
129
- * Create a mailing list in James
130
- */
131
- private createMailingList;
132
83
  /**
133
84
  * Create a team mailbox in James
134
85
  */
135
86
  private createTeamMailbox;
136
- /**
137
- * Delete a team mailbox from James
138
- */
139
- private deleteTeamMailbox;
140
- /**
141
- * Remove all members from a team mailbox (without deleting it)
142
- * This preserves the mailbox for potential future use
143
- */
144
- private removeAllTeamMailboxMembers;
145
- /**
146
- * Delete a mailing list from James
147
- */
148
- private deleteMailingList;
149
87
  /**
150
88
  * Get email addresses for a list of member DNs
151
89
  * Uses p-limit to parallelize LDAP queries while limiting concurrency
@@ -875,7 +875,7 @@ Ensure all required fields are included:
875
875
 
876
876
  ```json
877
877
  {
878
- "error": "Invalid value for attribute \"mail\": must match ^[^@\\s]+@[^@\\s]+\\.[^@\\s]+$"
878
+ "error": "Invalid value for attribute \"mail\": Expected an email address, e.g. jane.doe@example.org"
879
879
  }
880
880
  ```
881
881