ldap-rest 0.17.1 → 0.19.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 (73) hide show
  1. package/dist/abstract/ldapFlat.js +1 -1
  2. package/dist/abstract/twakePlugin.js +1 -1
  3. package/dist/config/args.js +1 -1
  4. package/dist/config/schema.js +1 -1
  5. package/dist/lib/ldapActions.js +1 -1
  6. package/dist/plugins/ldap/enterpriseRules.js +1 -1
  7. package/dist/plugins/ldap/externalUsersInGroups.js +1 -1
  8. package/dist/plugins/ldap/groups.js +1 -1
  9. package/dist/plugins/ldap/organizations.js +1 -1
  10. package/dist/plugins/twake/james.js +1 -1
  11. package/dist/src/abstract/ldapFlat.d.ts +9 -11
  12. package/dist/src/browser/ldap-group-editor/types.d.ts +2 -1
  13. package/dist/src/browser/ldap-resource-editor/types.d.ts +3 -2
  14. package/dist/src/browser/ldap-unit-editor/types.d.ts +2 -1
  15. package/dist/src/browser/ldap-user-editor/types.d.ts +3 -2
  16. package/dist/src/browser/shared/types/index.d.ts +7 -1
  17. package/dist/src/browser/shared/utils/schema.d.ts +20 -1
  18. package/dist/src/config/args.d.ts +1 -0
  19. package/dist/src/config/schema.d.ts +27 -4
  20. package/dist/src/plugins/ldap/externalUsersInGroups.d.ts +11 -0
  21. package/dist/src/plugins/ldap/organizations.d.ts +0 -8
  22. package/dist/src/plugins/twake/james.d.ts +63 -75
  23. package/docs/client-development/api/reference.md +1 -1
  24. package/docs/client-development/api/rest-api.md +13 -1
  25. package/docs/client-development/schemas/README.md +18 -13
  26. package/docs/plugin-development/dependencies.md +1 -3
  27. package/docs/presentations/en.md +0 -28
  28. package/docs/presentations/fr.md +0 -28
  29. package/docs/usage/configuration.md +13 -12
  30. package/docs/usage/plugins/integrations/james-mail.md +92 -129
  31. package/docs/usage/plugins/integrations/james-mailboxes.md +70 -83
  32. package/docs/usage/plugins/ldap/external-users.md +23 -2
  33. package/docs/usage/plugins/ldap/flat-generic.md +23 -5
  34. package/docs/usage/plugins/utilities/weblogs.md +1 -1
  35. package/docs/usage/upgrading.md +48 -0
  36. package/package.json +1 -1
  37. package/static/browser/ldap-resource-editor/api/ResourceApiClient.js +2 -1
  38. package/static/browser/ldap-resource-editor/api/ResourceApiClient.js.map +1 -1
  39. package/static/browser/ldap-user-editor/api/UserApiClient.js +2 -1
  40. package/static/browser/ldap-user-editor/api/UserApiClient.js.map +1 -1
  41. package/static/browser/ldap-user-editor/components/UserEditor.js +11 -8
  42. package/static/browser/ldap-user-editor/components/UserEditor.js.map +1 -1
  43. package/static/browser/ldap-user-editor/components/UserList.js +3 -1
  44. package/static/browser/ldap-user-editor/components/UserList.js.map +1 -1
  45. package/static/browser/ldap-user-editor.esm.js +52 -10
  46. package/static/browser/ldap-user-editor.esm.js.map +1 -1
  47. package/static/browser/ldap-user-editor.js +52 -10
  48. package/static/browser/ldap-user-editor.js.map +1 -1
  49. package/static/browser/ldap-user-editor.min.js +1 -1
  50. package/static/browser/ldap-user-editor.min.js.map +1 -1
  51. package/static/browser/shared/index.js +1 -1
  52. package/static/browser/shared/types/index.js.map +1 -1
  53. package/static/browser/shared/utils/form.js +2 -1
  54. package/static/browser/shared/utils/form.js.map +1 -1
  55. package/static/browser/shared/utils/schema.js +33 -1
  56. package/static/browser/shared/utils/schema.js.map +1 -1
  57. package/static/browser/shared.esm.js +153 -121
  58. package/static/browser/shared.esm.js.map +1 -1
  59. package/static/browser/shared.js +154 -120
  60. package/static/browser/shared.js.map +1 -1
  61. package/static/browser/shared.min.js +1 -1
  62. package/static/browser/shared.min.js.map +1 -1
  63. package/static/schemas/standard/groups.json +1 -1
  64. package/static/schemas/twake/groups.json +53 -18
  65. package/static/schemas/twake/nomenclature/twakeAccountStatus.json +24 -8
  66. package/static/schemas/twake/nomenclature/twakeDeliveryMode.json +22 -7
  67. package/static/schemas/twake/nomenclature/twakeDomain.json +21 -8
  68. package/static/schemas/twake/nomenclature/twakeListType.json +22 -7
  69. package/static/schemas/twake/nomenclature/twakeMailboxType.json +22 -7
  70. package/static/schemas/twake/nomenclature/twakeTitle.json +32 -12
  71. package/static/schemas/twake/organizations.json +29 -13
  72. package/static/schemas/twake/positions.json +13 -5
  73. package/static/schemas/twake/users.json +103 -38
@@ -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";const n=[400,404];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 l=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/${l}/size`,"PUT",e,t.toString(),{mail:l,quota:t},n)}if(r){const t=this.getAliases(r);await Promise.all(t.map(t=>this.callWebAdminApi("ldapadddone:alias",`${this.webadminUrl}/address/aliases/${l}/sources/${t}`,"PUT",e,null,{mail:l,alias:t},n)))}const o=this.getDisplayNameFromAttributes(i);o&&await this.updateJamesIdentity(e,l,o)},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},n);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},n))])}}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)),l=s.filter(t=>!r.includes(t)),o=r.filter(t=>!s.includes(t));await Promise.all([...l.map(i=>this.callWebAdminApi("onLdapAliasChange-delete",`${this.webadminUrl}/address/aliases/${e}/sources/${i}`,"DELETE",t,null,{mail:e,alias:i,action:"delete"})),...o.map(i=>this.callWebAdminApi("onLdapAliasChange-add",`${this.webadminUrl}/address/aliases/${e}/sources/${i}`,"PUT",t,null,{mail:e,alias:i,action:"add"},n))])},onLdapQuotaChange:(t,e,i,a)=>this.callWebAdminApi("onLdapQuotaChange",`${this.webadminUrl}/quota/users/${e}/size`,"PUT",t,a.toString(),{oldQuota:i,newQuota:a},n),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)),l=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"})),...l.map(i=>this.callWebAdminApi("onLdapForwardChange-add",`${this.webadminUrl}/address/forwards/${e}/targets/${i}`,"PUT",t,null,{mail:e,forward:i,domain:s,action:"add"},n))])},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,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`,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};for(let r=1;;r++){try{const l=`${this.webadminUrl}/users/${i}/identities`,o=await this.requestLimit(()=>t(`${l}?default=true`,{method:"GET",headers:this.createHeaders()}));let m;if(o.ok){const t=await o.json();m=(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!==o.status){const t="get_default_identity";if(this.shouldRetry(o.status,n,r)){await this.warnAndWait(s,t,r,o);continue}return void this.logger.error({...s,step:t,...r>1?{attempts:r}:{},http_status:o.status,http_status_text:o.statusText})}const d=await this.generateSignature(e),h=m?.id?{id:m.id,email:m.email,name:a}:{email:i,name:a,sortOrder:0};d&&(h.htmlSignature=d);const u=m?.id?"update_identity":"create_identity",g=await this.requestLimit(()=>t(m?.id?`${l}/${m.id}`:l,{method:m?.id?"PUT":"POST",headers:this.createHeaders("application/json"),body:JSON.stringify(h)}));if(g.ok)this.logger.info({...s,step:u,result:"success",http_status:g.status});else{if(this.shouldRetry(g.status,n,r)){await this.warnAndWait(s,u,r,g);continue}this.logger.error({...s,step:u,...r>1?{attempts:r}:{},http_status:g.status,http_status_text:g.statusText})}}catch(t){this.logger.error({...s,error:`${t}`})}return}}async warnAndWait(t,e,i,a){this.logger.warn({...t,step:e,result:"retry",attempt:i,http_status:a.status,http_status_text:a.statusText}),await this.waitBeforeRetry(i)}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);await this.callWebAdminApi("ldapgroupadddone:teamMailbox:create",`${this.webadminUrl}/domains/${a}/team-mailboxes/${i}`,"PUT",t,null,{domain:a,teamMailbox:i});const l=[];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,l]=e[s]||[],o=this._normalizeToArray(r),m=this._normalizeToArray(l),d=m.filter(t=>!o.includes(t)),h=o.filter(t=>!m.includes(t)),u=d.map(t=>this.server.ldap.queryLimit(async()=>{const e=await this._getDelegateEmail(t);return{dn:t,email:e}})),g=h.map(t=>this.server.ldap.queryLimit(async()=>{const e=await this._getDelegateEmail(t);return{dn:t,email:e}})),[c,p]=await Promise.all([Promise.all(u),Promise.all(g)]),b=[];for(const{dn:e,email:i}of c)i&&b.push(this.callWebAdminApi("onLdapChange:addDelegation",`${this.webadminUrl}/users/${a}/authorizedUsers/${i}`,"PUT",t,null,{userMail:a,delegateEmail:i,delegateDN:e,action:"add"},n));for(const{dn:e,email:i}of p)i&&b.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(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};
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],pickDefaultIdentity=t=>(Array.isArray(t)?t:[t]).filter(t=>t&&t.id&&!1!==t.mayDelete).sort((t,e)=>(t.sortOrder??0)-(e.sortOrder??0))[0],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:([t,e])=>this.provisionAccount(t,e),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)return void this.logger.debug(`Skipping mail rename for ${t}: oldmail is empty (mail attribute was added, not changed)`);if(!s)return void this.logger.debug(`Skipping mail rename for ${t}: newmail is empty (mail attribute was deleted, not changed)`);if(await this.isGroupDn(t))return;const r=await this.defaultSignatures(i),n=await this.ldapGetAttributes(t,[this.forwardAttr,this.displayNameAttr,"cn","givenName","sn",this.mailAttr]);await this.dropStaleForwards(t,s,this.ownForwards(n?.[this.forwardAttr])),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)}const l=n&&this.getDisplayNameFromAttributes(n);l&&await this.updateJamesIdentity(t,s,l,r)},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(async a=>{await this.dropStaleForwards(t,a,[]),await 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(!this.config.james_sync_forwards)return;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:async(t,e,a)=>{e&&a&&!e[this.mailAttr]&&a[this.mailAttr]&&await this.provisionAccount(t,a),await 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.forwardAttr=this.config.forward_attribute||"mailForwardingAddress",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),d=`${this.webadminUrl}/quota/users/${l}`,u=await this.requestLimit(()=>t(d,{method:"GET",headers:this.createHeaders()}));if(!u.ok)return void(404===u.status?a.status(404).json({error:`Quota not found for user ${s}`}):a.status(502).json({error:`Failed to retrieve quota from James: ${u.statusText}`}));const m=await u.json();a.json(m)}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),i=this.config.james_mailing_list_branch||[];if(i.some(t=>a===t||a.endsWith(","+t)))return{mail:e,refused:`Team mailbox cannot be in mailing list branches: ${i.join(", ")}`};const s=e.lastIndexOf("@");return/^[#a-zA-Z0-9_-]{1,255}$/.test(e.slice(0,s))&&this.extractMailDomain(e)?{mail:e,refused:null}:{mail:e,refused:"A team mailbox address is a name of letters, digits, #, - and _ at a domain"}}async provisionAccount(t,e){const a=e[this.mailAttr],i=e[this.quotaAttr],s=e[this.aliasAttr];if(!a||isGroup(e.objectClass))return;const r=Array.isArray(a)?String(a[0]):String(a);if(this.initDelay>0&&await new Promise(t=>setTimeout(t,this.initDelay)),i){const e=Array.isArray(i)?Number(i[0]):Number(i);!isNaN(e)&&e>0&&await this.callWebAdminApi("ldapadddone:quota",`${this.webadminUrl}/quota/users/${r}/size`,"PUT",t,e.toString(),{mail:r,quota:e},o)}const n=this.getAliases(s);await Promise.all([this.dropStaleForwards(t,r,this.ownForwards(e[this.forwardAttr])),...n.map(e=>this.dropStaleForwards(t,e,[]))]),n.length>0&&await Promise.all(n.map(e=>this.callWebAdminApi("ldapadddone:alias",`${this.webadminUrl}/address/aliases/${r}/sources/${e}`,"PUT",t,null,{mail:r,alias:e},o)));const l=this.getDisplayNameFromAttributes(e);l&&await this.updateJamesIdentity(t,r,l)}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&&i.refused!==a?.refused&&this.logger.error({plugin:this.name,event:"teamMailbox",dn:s,mail:i.mail,error:i.refused});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)}}teamMailboxUrl(t){const e=t.toLowerCase(),a=e.lastIndexOf("@");return`${this.webadminUrl}/domains/${e.slice(a+1)}/team-mailboxes/${encodeURIComponent(e.slice(0,a))}`}async teamMailboxMembers(t,e,a,i){if(!i.length)return;const s=this.teamMailboxUrl(e),r=await this.getMemberEmails(i);await Promise.all(r.map(i=>this.callWebAdminApi("teamMailbox:"+("PUT"===a?"addMember":"removeMember"),`${s}/members/${i}`,a,t,null,{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,i={}){const s=(this.identityQueue.get(e)??Promise.resolve()).then(()=>this.doUpdateJamesIdentity(t,e,a,i));this.identityQueue.set(e,s);try{await s}finally{this.identityQueue.get(e)===s&&this.identityQueue.delete(e)}}async doUpdateJamesIdentity(e,a,i,s){const r={plugin:this.name,event:"onLdapDisplayNameChange",result:"error",dn:e,mail:a,displayName:i};for(let n=1;;n++){try{const l=`${this.webadminUrl}/users/${a}/identities`,d=await this.requestLimit(()=>t(`${l}?default=true`,{method:"GET",headers:this.createHeaders()}));let u;if(d.ok)u=pickDefaultIdentity(await d.json());else if(404!==d.status){const t="get_default_identity";if(this.shouldRetry(d.status,o,n)){await this.warnAndWait(r,t,n,d);continue}return void this.logger.error({...r,step:t,...n>1?{attempts:n}:{},http_status:d.status,http_status_text:d.statusText})}const m=await this.generateSignature(e),h=u?.id?{id:u.id,email:u.email,name:i}:{email:a,name:i,sortOrder:0};m?h.htmlSignature=m:Object.assign(h,s);const c=u?.id?"update_identity":"create_identity",g=await this.requestLimit(()=>t(u?.id?`${l}/${u.id}`:l,{method:u?.id?"PUT":"POST",headers:this.createHeaders("application/json"),body:JSON.stringify(h)}));if(g.ok)this.logger.info({...r,step:c,result:"success",http_status:g.status});else{if(this.shouldRetry(g.status,o,n)){await this.warnAndWait(r,c,n,g);continue}this.logger.error({...r,step:c,...n>1?{attempts:n}:{},http_status:g.status,http_status_text:g.statusText})}}catch(t){this.logger.error({...r,error:`${t}`})}return}}async defaultSignatures(e){try{const a=await this.requestLimit(()=>t(`${this.webadminUrl}/users/${e}/identities?default=true`,{method:"GET",headers:this.createHeaders()}));if(!a.ok)return{};const i=pickDefaultIdentity(await a.json()),s={};return i?.htmlSignature&&(s.htmlSignature=i.htmlSignature),i?.textSignature&&(s.textSignature=i.textSignature),s}catch(t){return this.logger.debug(`Could not read the identities of ${e}: ${t}`),{}}}ownForwards(t){return this.config.james_sync_forwards?this.getAliases(t):[]}async dropStaleForwards(e,a,i){let s;try{const r=await this.requestLimit(()=>t(`${this.webadminUrl}/address/forwards/${a}`,{method:"GET",headers:this.createHeaders()}));if(!r.ok)return void(404!==r.status&&this.logger.warn({plugin:this.name,event:"dropStaleForward",result:"error",dn:e,address:a,step:"list_forwards",http_status:r.status,http_status_text:r.statusText}));const n=i.map(t=>t.toLowerCase());s=(await r.json()).map(t=>t.mailAddress).filter(t=>t&&!n.includes(t.toLowerCase()))}catch(t){return void this.logger.debug(`Could not read the forwards of ${a}: ${t}`)}await Promise.all(s.map(t=>this.callWebAdminApi("dropStaleForward",`${this.webadminUrl}/address/forwards/${a}/targets/${t}`,"DELETE",e,null,{address:a,forward:t})))}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.teamMailboxUrl(a);await this.callWebAdminApi("teamMailbox:create",i,"PUT",t,null,{teamMailbox:a}),await this.teamMailboxMembers(t,a,"PUT",members(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 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),d=this._normalizeToArray(n),u=d.filter(t=>!l.includes(t)),m=l.filter(t=>!d.includes(t)),h=u.map(t=>this.server.ldap.queryLimit(async()=>{const e=await this._getDelegateEmail(t);return{dn:t,email:e}})),c=m.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)]),f=[];for(const{dn:e,email:a}of g)a&&f.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&&f.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(f)}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
@@ -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;
@@ -170,6 +170,7 @@ export interface Config {
170
170
  james_retry_delay?: number;
171
171
  james_mailing_list_branch?: string[];
172
172
  james_mailbox_type_attribute?: string;
173
+ james_sync_forwards?: boolean;
173
174
  twake_drive_webadmin_url?: string;
174
175
  twake_drive_webadmin_token?: string;
175
176
  twake_drive_concurrency?: number;
@@ -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
  }
@@ -18,4 +18,15 @@ export default class ExternalUsersInGroups extends DmPlugin {
18
18
  ldap: ldapActions;
19
19
  constructor(server: DM);
20
20
  hooks: Hooks;
21
+ /**
22
+ * The address of an external member, unescaped: the value of a `mail` RDN
23
+ * right under the branch. Null for any other member.
24
+ */
25
+ externalAddress(member: string): string | null;
26
+ /** `--mail-domain`, lower-cased: the domains no external member may use */
27
+ managedDomains(): string[];
28
+ /**
29
+ * Where a client sends an external member: `mail=<escaped address>,<branch>`
30
+ */
31
+ getConfigApiData(): Record<string, unknown>;
21
32
  }
@@ -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,16 @@ 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
- */
8
+ type Identity = {
9
+ id: string;
10
+ email: string;
11
+ mayDelete?: boolean;
12
+ sortOrder?: number;
13
+ htmlSignature?: string;
14
+ textSignature?: string;
15
+ };
16
+ /** Signatures an identity carries over, see {@link Identity} */
17
+ export type Signatures = Pick<Identity, 'htmlSignature' | 'textSignature'>;
55
18
  export default class James extends TwakePlugin {
56
19
  name: string;
57
20
  roles: Role[];
@@ -62,6 +25,7 @@ export default class James extends TwakePlugin {
62
25
  private quotaAttr;
63
26
  private aliasAttr;
64
27
  private mailboxTypeAttr;
28
+ private forwardAttr;
65
29
  private initDelay;
66
30
  constructor(server: DM);
67
31
  /**
@@ -74,14 +38,37 @@ export default class James extends TwakePlugin {
74
38
  private getUserQuota;
75
39
  hooks: Hooks;
76
40
  /**
77
- * Handle mailbox type transitions when twakeMailboxType changes
41
+ * The address of the team mailbox an entry stands for, and why it is
42
+ * refused, if it is: it lies in a mailing list branch, or TMail cannot
43
+ * name a team mailbox after its address.
78
44
  */
79
- private handleMailboxTypeTransition;
45
+ private teamMailboxOf;
46
+ /**
47
+ * Give James what an account holds: quota, aliases and identity, after
48
+ * dropping the forwards left on its addresses. Runs when an entry is
49
+ * added with a mail, or gains one.
50
+ */
51
+ private provisionAccount;
52
+ /**
53
+ * Bring James from the team mailbox an entry was to the one it is. A
54
+ * mailbox the entry no longer stands for is kept, emptied of its members.
55
+ */
56
+ private followTeamMailbox;
57
+ private teamMailboxQueue;
58
+ /**
59
+ * The WebAdmin path of the team mailbox at an address
60
+ * {@link teamMailboxOf} accepted: TMail names it by the local part, under
61
+ * its domain. Lower-cased, so that a change of case only, which keeps the
62
+ * mailbox, keeps its path.
63
+ */
64
+ private teamMailboxUrl;
65
+ private teamMailboxMembers;
80
66
  /**
81
67
  * Extract display name from LDAP attributes
82
68
  * Fallback logic: displayName → cn → givenName+sn → mail
83
69
  */
84
70
  private getDisplayNameFromAttributes;
71
+ private isGroupDn;
85
72
  getMailFromDN(dn: string): Promise<string | null>;
86
73
  getDisplayNameFromDN(dn: string): Promise<string | null>;
87
74
  generateSignature(dn: string): Promise<string | null>;
@@ -100,10 +87,35 @@ export default class James extends TwakePlugin {
100
87
  * Calls for one address run one after the other: an add triggers this twice
101
88
  * (ldapadddone, then the display name "change" onChange publishes), and two
102
89
  * concurrent first calls would both find no default and both create one.
90
+ *
91
+ * `signatures` are written when `--james-signature-template` is not set:
92
+ * those of the identity the user had before a rename.
103
93
  */
104
- updateJamesIdentity(dn: string, mail: string, displayName: string): Promise<void>;
94
+ updateJamesIdentity(dn: string, mail: string, displayName: string, signatures?: Signatures): Promise<void>;
105
95
  private identityQueue;
106
96
  private doUpdateJamesIdentity;
97
+ /**
98
+ * The signatures of an address's default identity, none when it has none
99
+ * or James cannot be read.
100
+ */
101
+ private defaultSignatures;
102
+ /**
103
+ * The forwards the directory gives an entry, those James is to keep on its
104
+ * address: none unless the directory owns forwards
105
+ * (`--james-sync-forwards`).
106
+ */
107
+ private ownForwards;
108
+ /**
109
+ * Remove the forwards James holds from an address an entry takes, but
110
+ * those in `keep`: the entry's own. A rename leaves one from the old
111
+ * address to the new, and its next owner would not get its mail.
112
+ *
113
+ * Runs whether forwards are synced or not: Twake Mail keeps a user's
114
+ * forwards under their own address, and none can have been set yet on an
115
+ * address the account is only now taking. The rename calls this before
116
+ * James moves the user's forwards to the new address.
117
+ */
118
+ private dropStaleForwards;
107
119
  private warnAndWait;
108
120
  /**
109
121
  * Delete user data via James WebAdmin API
@@ -113,40 +125,15 @@ export default class James extends TwakePlugin {
113
125
  deleteUserData(mail: string, fromStep?: string): Promise<{
114
126
  taskId: string;
115
127
  } | null>;
116
- /**
117
- * Type guard to check if delete operation is an AttributesList
118
- */
119
- private isAttributesList;
120
128
  /**
121
129
  * Extract mailbox type from group attributes
122
130
  * Returns: 'group', 'mailingList', 'teamMailbox', or null if not set
123
131
  */
124
132
  private getMailboxType;
125
- /**
126
- * Validate that a DN is within one of the allowed branches
127
- */
128
- private isInAllowedBranches;
129
- /**
130
- * Create a mailing list in James
131
- */
132
- private createMailingList;
133
133
  /**
134
134
  * Create a team mailbox in James
135
135
  */
136
136
  private createTeamMailbox;
137
- /**
138
- * Delete a team mailbox from James
139
- */
140
- private deleteTeamMailbox;
141
- /**
142
- * Remove all members from a team mailbox (without deleting it)
143
- * This preserves the mailbox for potential future use
144
- */
145
- private removeAllTeamMailboxMembers;
146
- /**
147
- * Delete a mailing list from James
148
- */
149
- private deleteMailingList;
150
137
  /**
151
138
  * Get email addresses for a list of member DNs
152
139
  * Uses p-limit to parallelize LDAP queries while limiting concurrency
@@ -164,3 +151,4 @@ export default class James extends TwakePlugin {
164
151
  _getDelegateEmail(dn: string): Promise<string | null>;
165
152
  _normalizeToArray(value: AttributeValue | null): string[];
166
153
  }
154
+ export {};
@@ -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
 
@@ -245,6 +245,8 @@ curl -H "Accept: application/json" \
245
245
  - `features.ldapFlatGeneric.flatResources`: Array of flat LDAP resources (users, positions, etc.)
246
246
  - `features.ldapGroups`: Group management configuration (if enabled)
247
247
  - `features.ldapOrganizations`: Organization tree configuration (if enabled)
248
+ - `features.externalUsersInGroups`: where to send an external group member
249
+ (if enabled), [details](../../usage/plugins/ldap/external-users.md#config-api)
248
250
  - `features.openidconnect.endpoints.logout`: the logout route, when OpenID Connect
249
251
  guards the server ([details](../../usage/plugins/auth/oidc.md#logout))
250
252
 
@@ -1075,6 +1077,16 @@ curl -X POST \
1075
1077
  }
1076
1078
  ```
1077
1079
 
1080
+ A member that does not exist answers 400 naming it, and none of the members
1081
+ is added (unless `--group-allow-unexistent-members` is set). Group creation
1082
+ checks its `member` list the same way:
1083
+
1084
+ ```json
1085
+ {
1086
+ "error": "Member uid=nobody,ou=users,dc=example,dc=com not found"
1087
+ }
1088
+ ```
1089
+
1078
1090
  ### Remove Group Member
1079
1091
 
1080
1092
  Remove a user from a group.
@@ -1146,7 +1158,7 @@ All errors return a JSON object with an error message:
1146
1158
 
1147
1159
  ```json
1148
1160
  {
1149
- "error": "Invalid value for attribute \"uid\": must match ^[a-zA-Z0-9._-]{1,255}$"
1161
+ "error": "Invalid value for attribute \"uid\""
1150
1162
  }
1151
1163
  ```
1152
1164