ldap-rest 0.16.0 → 0.16.1

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.
@@ -1 +1 @@
1
- import e from"../../abstract/plugin.js";import{launchHooks as t}from"../../lib/utils.js";import{currentOperation as n}from"../../lib/operation.js";const normalize=e=>(void 0===e?[]:Array.isArray(e)?e:[e]).map(e=>Buffer.isBuffer(e)?e.toString("base64"):String(e)).sort();function diffEntries(e,t){const index=e=>new Map(Object.keys(e||{}).filter(e=>"dn"!==e).map(e=>[e.toLowerCase(),e])),n=index(e),i=index(t),a={};for(const r of new Set([...n.keys(),...i.keys()])){const s=n.get(r),o=i.get(r),d=s?e[s]:void 0,h=o?t[o]:void 0,l=normalize(d),c=normalize(h);l.length===c.length&&l.every((e,t)=>e===c[t])||(a[o||s]=[l.length?d:null,c.length?h:null])}return a}const i={mail_attribute:"onLdapMailChange",quota_attribute:"onLdapQuotaChange",alias_attribute:"onLdapAliasChange",forward_attribute:"onLdapForwardChange",display_name_attribute:"onLdapDisplayNameChange",drive_quota_attribute:"onLdapDriveQuotaChange"};class OnLdapChange extends e{constructor(){super(...arguments),this.name="onLdapChange",this.roles=["consistency"],this.stack={},this.pendingDeletions=new Map,this.pendingRenames=new Map,this.hooks={ldapadddone:async([e,t],n)=>{const i=await this.read(e)||{dn:e,...t};this.publish(e,null,i,n)},ldapmodifyrequest:async([e,t,n,i])=>{const a=await this.read(e);return a?this.stack[n]=a:this.logger.warn(`Could not read ${e} before modification`),[e,t,n,i]},ldapmodifydone:async([e,t,n],i)=>{const a=this.stack[n];if(delete this.stack[n],!a)return void this.logger.warn(`Received a ldapmodifydone for an unknown operation (${n})`);if(0===Object.keys(t).length)return;const r=await this.read(e);r?this.publish(e,a,r,i):this.logger.warn(`Could not read ${e} after modification`)},ldapmodifyend:e=>{delete this.stack[e]},ldaprenamerequest:async([e,t,i])=>{const a=n(),r=void 0===a?void 0:await this.read(e);return void 0!==a&&r&&this.pendingRenames.set(a,{dn:e,entry:r}),[e,t,i]},ldaprenamedone:async([e,t],i)=>{const a=n(),r=void 0===a?void 0:this.pendingRenames.get(a);if(!r||r.dn!==e)return void this.logger.debug(`${e}: no snapshot: a move made outside a request`);this.pendingRenames.delete(a);const s=await this.read(t);s?this.publish(t,r.entry,s,i):this.logger.warn(`Could not read ${t} after the rename of ${e}`)},ldaprenameend:e=>{this.pendingRenames.delete(e)},ldapdeleterequest:async([e,t])=>{const i=n();if(void 0===i)return[e,t];for(const t of Array.isArray(e)?e:[e]){const e=await this.read(t);if(!e)continue;let n=this.pendingDeletions.get(i);n||this.pendingDeletions.set(i,n=new Map),n.set(t,e)}return[e,t]},ldapdeletedone:(e,t)=>{const i=n(),a=void 0===i?void 0:this.pendingDeletions.get(i);for(const n of Array.isArray(e)?e:[e]){const e=a?.get(n);e&&(a.delete(n),this.publish(n,e,null,t))}},ldapdeleteend:e=>{this.pendingDeletions.delete(e)}}}async read(e){const t=new Set(Object.values(this.server.loadedPlugins).flatMap(e=>e.followedOperationalAttributes||[]));try{const n=(await this.server.ldap.search({paged:!1,scope:"base",...t.size?{attributes:["*",...t]}:{}},e)).searchEntries[0];if(!n)return;return Object.fromEntries(Object.entries(n).filter(([,e])=>!Array.isArray(e)||e.length>0))}catch(e){return}}publish(e,n,i,a={}){const r=diffEntries(n,i);0===Object.keys(r).length&&n?.dn===i?.dn||(t(this.server.hooks.onLdapEntryChange,e,n,i,a),this.notify(e,r,n,i))}notify(e,n,a,r){t(this.server.hooks.onLdapChange,e,n);for(const[a,s]of Object.entries(i))if(this.config[a]&&n[this.config[a]])if("onLdapQuotaChange"===s||"onLdapForwardChange"===s||"onLdapAliasChange"===s)this.notifyAttributeChangeWithMail(this.config[a],s,e,n,r);else if("onLdapMailChange"===s)this.notifyAttributeChange(this.config[a],s,e,n);else if("onLdapDriveQuotaChange"===s){const[i,r]=n[this.config[a]]||[],s=i?Number(i):null,o=r?Number(r):null;s!==o&&t(this.server.hooks.onLdapDriveQuotaChange,e,s,o)}const s=this.reconstructDisplayName(a),o=this.reconstructDisplayName(r);s!==o&&t(this.server.hooks.onLdapDisplayNameChange,e,s,o)}notifyAttributeChange(e,n,i,a,r=!1){const[s,o]=a[e]||[];void 0===s&&void 0===o||(r&&(Array.isArray(s)||Array.isArray(o))?this.logger.error(`Attribute ${e} change detected but one of the values is an array, cannot handle that`):s!==o&&t(this.server.hooks[n],i,s,o))}notifyAttributeChangeWithMail(e,n,i,a,r){const[s,o]=a[e]||[];if(void 0===s&&void 0===o)return;const d=this.config.mail_attribute||"mail",h=a[d];let l;if(h)l=Array.isArray(h[1])?String(h[1][0]):String(h[1]);else{if(!r?.[d])return void this.logger.warn(`Could not find mail for ${i}, skipping ${n} notification`);{const e=r[d];l=Array.isArray(e)?String(e[0]):String(e)}}if("onLdapQuotaChange"===n){const e=s?Number(s):0,a=o?Number(o):0;e!==a&&t(this.server.hooks[n],i,l,e,a)}else if("onLdapForwardChange"===n||"onLdapAliasChange"===n){const e=s?Array.isArray(s)?s:[s]:[],a=o?Array.isArray(o)?o:[o]:[];(e.length>0||a.length>0)&&t(this.server.hooks[n],i,l,e,a)}}reconstructDisplayName(e){const getValue=t=>{const n=e?.[t];return n?Array.isArray(n)?n.length>0?String(n[0]):null:String(n):null},t=getValue("cn");if(t)return t;const n=getValue("givenName"),i=getValue("sn");if(n||i){const e=[];return n&&e.push(n),i&&e.push(i),e.join(" ")}return null}}export{OnLdapChange as default,diffEntries};
1
+ import e from"../../abstract/plugin.js";import{launchHooks as t}from"../../lib/utils.js";import{currentOperation as n}from"../../lib/operation.js";const normalize=e=>(void 0===e?[]:Array.isArray(e)?e:[e]).map(e=>Buffer.isBuffer(e)?e.toString("base64"):String(e)).sort();function diffEntries(e,t){const index=e=>new Map(Object.keys(e||{}).filter(e=>"dn"!==e).map(e=>[e.toLowerCase(),e])),n=index(e),i=index(t),a={};for(const r of new Set([...n.keys(),...i.keys()])){const s=n.get(r),o=i.get(r),d=s?e[s]:void 0,h=o?t[o]:void 0,l=normalize(d),u=normalize(h);l.length===u.length&&l.every((e,t)=>e===u[t])||(a[o||s]=[l.length?d:null,u.length?h:null])}return a}const i={mail_attribute:"onLdapMailChange",quota_attribute:"onLdapQuotaChange",alias_attribute:"onLdapAliasChange",forward_attribute:"onLdapForwardChange",display_name_attribute:"onLdapDisplayNameChange",drive_quota_attribute:"onLdapDriveQuotaChange"};class OnLdapChange extends e{constructor(){super(...arguments),this.name="onLdapChange",this.roles=["consistency"],this.stack={},this.pendingDeletions=new Map,this.pendingRenames=new Map,this.hooks={ldapadddone:async([e,t],n)=>{const i=await this.read(e)||{dn:e,...t};this.publish(e,null,i,n)},ldapmodifyrequest:async([e,t,n,i])=>{const a=await this.read(e);return a?this.stack[n]=a:this.logger.warn(`Could not read ${e} before modification`),[e,t,n,i]},ldapmodifydone:async([e,t,n],i)=>{const a=this.stack[n];if(delete this.stack[n],!a)return void this.logger.warn(`Received a ldapmodifydone for an unknown operation (${n})`);if(0===Object.keys(t).length)return;const r=await this.read(e);r?this.publish(e,a,r,i):this.logger.warn(`Could not read ${e} after modification`)},ldapmodifyend:e=>{delete this.stack[e]},ldaprenamerequest:async([e,t,i])=>{const a=n(),r=void 0===a?void 0:await this.read(e);return void 0!==a&&r&&this.pendingRenames.set(a,{dn:e,entry:r}),[e,t,i]},ldaprenamedone:async([e,t],i)=>{const a=n(),r=void 0===a?void 0:this.pendingRenames.get(a);if(!r||r.dn!==e)return void this.logger.debug(`${e}: no snapshot: a move made outside a request`);this.pendingRenames.delete(a);const s=await this.read(t);s?this.publish(t,r.entry,s,i):this.logger.warn(`Could not read ${t} after the rename of ${e}`)},ldaprenameend:e=>{this.pendingRenames.delete(e)},ldapdeleterequest:async([e,t])=>{const i=n();if(void 0===i)return[e,t];for(const t of Array.isArray(e)?e:[e]){const e=await this.read(t);if(!e)continue;let n=this.pendingDeletions.get(i);n||this.pendingDeletions.set(i,n=new Map),n.set(t,e)}return[e,t]},ldapdeletedone:(e,t)=>{const i=n(),a=void 0===i?void 0:this.pendingDeletions.get(i);for(const n of Array.isArray(e)?e:[e]){const e=a?.get(n);e&&(a.delete(n),this.publish(n,e,null,t))}},ldapdeleteend:e=>{this.pendingDeletions.delete(e)}}}async read(e){const t=new Set(Object.values(this.server.loadedPlugins).flatMap(e=>e.followedOperationalAttributes||[]));try{const n=(await this.server.ldap.search({paged:!1,scope:"base",...t.size?{attributes:["*",...t]}:{}},e)).searchEntries[0];if(!n)return;return Object.fromEntries(Object.entries(n).filter(([,e])=>!Array.isArray(e)||e.length>0))}catch(e){return}}publish(e,n,i,a={}){const r=diffEntries(n,i);0===Object.keys(r).length&&n?.dn===i?.dn||(t(this.server.hooks.onLdapEntryChange,e,n,i,a),this.notify(e,r,n,i))}notify(e,n,a,r){t(this.server.hooks.onLdapChange,e,n);for(const[a,s]of Object.entries(i))if(this.config[a]&&n[this.config[a]])if("onLdapQuotaChange"===s||"onLdapForwardChange"===s||"onLdapAliasChange"===s)this.notifyAttributeChangeWithMail(this.config[a],s,e,n,r);else if("onLdapMailChange"===s)this.notifyAttributeChange(this.config[a],s,e,n);else if("onLdapDriveQuotaChange"===s){const[i,r]=n[this.config[a]]||[],s=i?Number(i):null,o=r?Number(r):null;s!==o&&t(this.server.hooks.onLdapDriveQuotaChange,e,s,o)}const s=this.reconstructDisplayName(a),o=this.reconstructDisplayName(r);s!==o&&t(this.server.hooks.onLdapDisplayNameChange,e,s,o)}notifyAttributeChange(e,n,i,a,r=!1){const[s,o]=a[e]||[];void 0===s&&void 0===o||(r&&(Array.isArray(s)||Array.isArray(o))?this.logger.error(`Attribute ${e} change detected but one of the values is an array, cannot handle that`):s!==o&&t(this.server.hooks[n],i,s,o))}notifyAttributeChangeWithMail(e,n,i,a,r){const[s,o]=a[e]||[];if(void 0===s&&void 0===o)return;const d=this.config.mail_attribute||"mail",h=a[d];let l;if(h)l=Array.isArray(h[1])?String(h[1][0]):String(h[1]);else{if(!r?.[d])return void this.logger.warn(`Could not find mail for ${i}, skipping ${n} notification`);{const e=r[d];l=Array.isArray(e)?String(e[0]):String(e)}}if("onLdapQuotaChange"===n){const e=s?Number(s):0,a=o?Number(o):0;e!==a&&t(this.server.hooks[n],i,l,e,a)}else if("onLdapForwardChange"===n||"onLdapAliasChange"===n){const e=s?Array.isArray(s)?s:[s]:[],a=o?Array.isArray(o)?o:[o]:[];(e.length>0||a.length>0)&&t(this.server.hooks[n],i,l,e,a)}}reconstructDisplayName(e){const getValue=t=>{if(!e)return null;const n=Object.keys(e).find(e=>e.toLowerCase()===t.toLowerCase()),i=void 0===n?void 0:e[n];return i?Array.isArray(i)?i.length>0?String(i[0]):null:String(i):null},t=getValue(this.config.display_name_attribute||"displayName");if(t)return t;const n=getValue("cn");if(n)return n;const i=getValue("givenName"),a=getValue("sn");if(i||a){const e=[];return i&&e.push(i),a&&e.push(a),e.join(" ")}return null}}export{OnLdapChange as default,diffEntries};
@@ -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.quotaAttr=this.config.quota_attribute||"mailQuotaSize",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(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,{method:"GET",headers:this.createHeaders()}));if(!n.ok)return void this.logger.error({...s,step:"get_identities",http_status:n.status,http_status_text:n.statusText});const l=await n.json(),o=l.find(t=>t.email===i)||l[0];if(!o)return void this.logger.warn({...s,step:"find_identity",message:"No identity found for user"});const m=await this.generateSignature(e),d=`${this.webadminUrl}/users/${i}/identities/${o.id}`,h={id:o.id,email:o.email,name:a};m&&(h.htmlSignature=m);const u=await this.requestLimit(()=>t(d,{method:"PUT",headers:this.createHeaders("application/json"),body:JSON.stringify(h)}));u.ok?this.logger.info({...s,result:"success",http_status:u.status}):this.logger.error({...s,step:"update_identity",http_status:u.status,http_status_text:u.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 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||"mailQuotaSize",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};
@@ -36,9 +36,15 @@ declare class OnLdapChange extends DmPlugin {
36
36
  notifyAttributeChange(attribute: string, hookName: keyof Hooks, dn: string, changes: ChangesToNotify, stringOnly?: boolean): void;
37
37
  notifyAttributeChangeWithMail(attribute: string, hookName: keyof Hooks, dn: string, changes: ChangesToNotify, after: Entry | null): void;
38
38
  /**
39
- * Reconstruct display name from cn, givenName, and sn attributes
39
+ * The name the James plugin gives an identity, in the same order: the
40
+ * display name attribute, then cn, then givenName + sn
41
+ * (`getDisplayNameFromAttributes` in plugins/twake/james.ts). Reading cn
42
+ * first, as this did, missed a change of displayName alone, the attribute
43
+ * the console and the schemas edit. The James plugin's last resort, the
44
+ * mail, is left out: a mail change already has its own hook, and an entry
45
+ * without any name would turn every mail change into a name change.
40
46
  * @param entry - The entry, before or after the change
41
- * @returns The reconstructed display name or null
47
+ * @returns The display name or null
42
48
  */
43
49
  reconstructDisplayName(entry: Entry | null): string | null;
44
50
  }
@@ -85,7 +85,25 @@ export default class James extends TwakePlugin {
85
85
  getMailFromDN(dn: string): Promise<string | null>;
86
86
  getDisplayNameFromDN(dn: string): Promise<string | null>;
87
87
  generateSignature(dn: string): Promise<string | null>;
88
+ /**
89
+ * Name the user's default identity after the directory.
90
+ *
91
+ * The default identity is Twake Mail's: the deletable one with the lowest
92
+ * sortOrder (`GET /users/{mail}/identities?default=true`). It is updated
93
+ * when there is one and created otherwise. The server-set identity
94
+ * (mayDelete false, named after the address) is never touched: a PUT on it
95
+ * makes Twake Mail add, a few seconds later, a deletable identity named
96
+ * "givenName sn", so the user ended up with two identities for one
97
+ * address, one of them named after neither the directory's display name
98
+ * nor the address.
99
+ *
100
+ * Calls for one address run one after the other: an add triggers this twice
101
+ * (ldapadddone, then the display name "change" onChange publishes), and two
102
+ * concurrent first calls would both find no default and both create one.
103
+ */
88
104
  updateJamesIdentity(dn: string, mail: string, displayName: string): Promise<void>;
105
+ private identityQueue;
106
+ private doUpdateJamesIdentity;
89
107
  /**
90
108
  * Delete user data via James WebAdmin API
91
109
  * Calls POST /users/{mail}?action=deleteData
@@ -267,7 +267,7 @@ Monitors LDAP modifications and triggers hooks for attribute changes. No configu
267
267
  - `onLdapQuotaChange` - Quota attribute changes
268
268
  - `onLdapAliasChange` - Alias attribute changes
269
269
  - `onLdapForwardChange` - Forward attribute changes
270
- - `onLdapDisplayNameChange` - Display name changes (cn, givenName, sn)
270
+ - `onLdapDisplayNameChange` - Display name changes (`--display-name-attribute`, else cn, else givenName and sn)
271
271
 
272
272
  #### `core/ldap/departmentSync`
273
273
 
@@ -146,6 +146,34 @@ When a user's quota changes:
146
146
  5000000000
147
147
  ```
148
148
 
149
+ ### Display Name and Identity
150
+
151
+ When a user is added, or their display name changes, the plugin names their
152
+ default identity in Twake Mail after the directory. The name is the first one
153
+ found in `--display-name-attribute` (`displayName` by default), `cn`, then
154
+ `givenName` and `sn`. The optional signature is `--james-signature-template`.
155
+
156
+ 1. **James plugin** reads the default identity, the deletable one with the
157
+ lowest `sortOrder`:
158
+
159
+ ```http
160
+ GET /users/jdoe@company.com/identities?default=true
161
+ ```
162
+
163
+ 2. It updates that identity, or creates it when the user has none (404):
164
+
165
+ ```http
166
+ PUT /users/jdoe@company.com/identities/{id}
167
+ POST /users/jdoe@company.com/identities
168
+ {"email": "jdoe@company.com", "name": "John Doe", "sortOrder": 0}
169
+ ```
170
+
171
+ The server-set identity, which James derives from the address and which
172
+ cannot be deleted, is left alone: an update of it makes Twake Mail add a
173
+ second identity for the same address, named "givenName sn". Calls for one
174
+ address run one after the other, so that the two hooks an add fires create a
175
+ single identity.
176
+
149
177
  ### Email Aliases Management
150
178
 
151
179
  The plugin automatically manages email aliases stored in LDAP and syncs them to James.
@@ -36,15 +36,15 @@ A write no request is behind, such as a scheduled task's, gets `{}`. The LDAP
36
36
 
37
37
  The other hooks are derived from it:
38
38
 
39
- | Hook | Fires when | Parameters |
40
- | ------------------------- | ---------------------------------------------------------- | -------------------------------------- |
41
- | `onLdapChange` | any attribute changed | `(dn, changes)` |
42
- | `onLdapMailChange` | `--mail-attribute` changed | `(dn, oldMail, newMail)` |
43
- | `onLdapQuotaChange` | `--quota-attribute` changed | `(dn, mail, oldQuota, newQuota)` |
44
- | `onLdapAliasChange` | `--alias-attribute` changed | `(dn, mail, oldAliases, newAliases)` |
45
- | `onLdapForwardChange` | `--forward-attribute` changed | `(dn, mail, oldForwards, newForwards)` |
46
- | `onLdapDriveQuotaChange` | `--drive-quota-attribute` changed | `(dn, oldQuota, newQuota)` |
47
- | `onLdapDisplayNameChange` | the name built from `cn`, or `givenName` and `sn`, changed | `(dn, oldName, newName)` |
39
+ | Hook | Fires when | Parameters |
40
+ | ------------------------- | --------------------------------------------------------------------------------------- | -------------------------------------- |
41
+ | `onLdapChange` | any attribute changed | `(dn, changes)` |
42
+ | `onLdapMailChange` | `--mail-attribute` changed | `(dn, oldMail, newMail)` |
43
+ | `onLdapQuotaChange` | `--quota-attribute` changed | `(dn, mail, oldQuota, newQuota)` |
44
+ | `onLdapAliasChange` | `--alias-attribute` changed | `(dn, mail, oldAliases, newAliases)` |
45
+ | `onLdapForwardChange` | `--forward-attribute` changed | `(dn, mail, oldForwards, newForwards)` |
46
+ | `onLdapDriveQuotaChange` | `--drive-quota-attribute` changed | `(dn, oldQuota, newQuota)` |
47
+ | `onLdapDisplayNameChange` | the name from `--display-name-attribute`, else `cn`, else `givenName` and `sn`, changed | `(dn, oldName, newName)` |
48
48
 
49
49
  `changes` maps each attribute that changed to `[oldValues, newValues]`, the
50
50
  full values on each side, `null` where the attribute is absent:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ldap-rest",
3
- "version": "0.16.0",
3
+ "version": "0.16.1",
4
4
  "description": "Light directory manager",
5
5
  "keywords": [
6
6
  "Identity-Manager"