ldap-rest 0.21.2 → 0.21.3

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 t from"node-fetch";import{TwakePlugin as e}from"../../abstract/twakePlugin.js";import{ConflictError as a}from"../../lib/errors.js";import{asyncHandler as i,escapeLdapFilter as s,isDummyMemberDn as r,normalizeDn as n}from"../../lib/utils.js";import{wantJson as o}from"../../lib/expressFormatedResponses.js";const l=[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=>valuesOf(t??void 0,"member")??[],sameAttr=(t,e)=>t.split(";")[0].toLowerCase()===e.split(";")[0].toLowerCase(),valuesOf=(t,e)=>{if(!t)return;const a=Object.keys(t).filter(t=>sameAttr(t,e));return a.length?a.flatMap(e=>{return null==(a=t[e])?[]:(Array.isArray(a)?a:[a]).map(String);var a}):void 0},memberKey=t=>{try{return n(t)}catch{return t.toLowerCase()}},m=["groupofnames","groupofuniquenames","groupofurls"],isGroup=t=>(Array.isArray(t)?t:t?[t]:[]).some(t=>m.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","configurable"],this.dependencies={onLdapChange:"core/ldap/onChange",ldapGroups:"core/ldap/groups"},this.hooks={ldapaddrequest:async([t,e,a])=>{const i=this.teamMailboxDomain(valuesOf(e,this.mailAttr),valuesOf(e,this.mailboxTypeAttr));return i&&await this.assertMembersAt(t,i,valuesOf(e,"member")??[]),void 0!==a?[t,e,a]:[t,e]},ldapmodifyrequest:async([t,e,a,i])=>(await this.guardTeamMailboxChange(t,e),[t,e,a,i]),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},l);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},l))])}}catch(t){this.logger.debug("Could not fetch aliases for mail change:",t)}const o=n&&this.getDisplayNameFromAttributes(n);o&&await this.updateJamesIdentity(t,s,o,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)),o=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"})),...o.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"},l)})])},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},l)},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"},l))])},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`,i((t,e)=>this.getUserQuota(t,e))),this.logger.info(`James API registered at ${e}/v1/users/:user/quota-usage`)}async getUserQuota(e,a){if(!o(e,a))return;const i=e.params.user;try{const e=s(i),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 ${i} not found`});const o=n[this.mailAttr];if(!o)return void a.status(400).json({error:`User ${i} has no mail attribute`});const l=Array.isArray(o)?String(o[0]):String(o),m=`${this.webadminUrl}/quota/users/${l}`,d=await this.requestLimit(()=>t(m,{method:"GET",headers:this.createHeaders()}));if(!d.ok)return void(404===d.status?a.status(404).json({error:`Quota not found for user ${i}`}):a.status(502).json({error:`Failed to retrieve quota from James: ${d.statusText}`}));const u=await d.json();a.json(u)}catch(t){this.logger.error({plugin:this.name,event:"getUserQuota",username:i,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[this.mailAttr]);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},l)}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},l)));const o=this.getDisplayNameFromAttributes(e);o&&await this.updateJamesIdentity(t,r,o)}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)}}getConfigApiData(){return{teamMailboxAnyDomain:Boolean(this.config.james_team_mailbox_any_domain)}}teamMailboxDomain(t,e){if(this.config.james_team_mailbox_any_domain||"teamMailbox"!==this.getMailboxType({[this.mailboxTypeAttr]:e}))return null;const a=this.attributeToString(t);return a&&this.extractMailDomain(a)?.toLowerCase()||null}async guardTeamMailboxChange(t,e){if(this.config.james_team_mailbox_any_domain)return;const a=[this.mailAttr,this.mailboxTypeAttr,"member"];if(![e.add,e.replace].some(t=>t&&Object.keys(t).some(t=>a.some(e=>sameAttr(t,e)))))return;const i=await this.ldapGetAttributes(t,a);if(!i)return;const after=(t,a=t=>t.toLowerCase())=>((t,e,a,i)=>{let s=valuesOf(t,a)??[];const r=valuesOf(e.add,a);r&&(s=[...s,...r]);const n=valuesOf(e.replace,a);n&&(s=n);const o=e.delete;if(Array.isArray(o))o.some(t=>sameAttr(t,a))&&(s=[]);else{const t=valuesOf(o,a);if(t){const e=new Set(t.map(i));s=e.size?s.filter(t=>!e.has(i(t))):[]}}return s})(i,e,t,a),s=this.teamMailboxDomain(after(this.mailAttr),after(this.mailboxTypeAttr));if(!s)return;const r=new Set((valuesOf(i,"member")??[]).map(memberKey)),n=after("member",memberKey),o=n.filter(t=>!r.has(memberKey(t))),l=s!==this.teamMailboxDomain(valuesOf(i,this.mailAttr),valuesOf(i,this.mailboxTypeAttr));await this.assertMembersAt(t,s,o,l?n.filter(t=>r.has(memberKey(t))):[])}async assertMembersAt(t,e,i,s=[]){const foreign=async t=>(await this.getMemberEmails(t)).filter(t=>this.extractMailDomain(t)?.toLowerCase()!==e),[r,n]=await Promise.all([foreign(i),foreign(s)]);if(0===r.length&&0===n.length)return;const o=r.length?`${r.slice(0,5).join(", ")}${r.length>5?", …":""} `+(r.length>1?"are":"is")+" not":"",l=n.length?"some of the members it holds are not":"";throw new a(`The members of team mailbox ${t} must have an address at ${e}: `+[o,l].filter(Boolean).join(", and "))}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 o=`${this.webadminUrl}/users/${a}/identities`,m=await this.requestLimit(()=>t(`${o}?default=true`,{method:"GET",headers:this.createHeaders()}));let d;if(m.ok)d=pickDefaultIdentity(await m.json());else if(404!==m.status){const t="get_default_identity";if(this.shouldRetry(m.status,l,n)){await this.warnAndWait(r,t,n,m);continue}return void this.logger.error({...r,step:t,...n>1?{attempts:n}:{},http_status:m.status,http_status_text:m.statusText})}const u=await this.generateSignature(e),h=d?.id?{id:d.id,email:d.email,name:i}:{email:a,name:i,sortOrder:0};u?h.htmlSignature=u:Object.assign(h,s);const c=d?.id?"update_identity":"create_identity",g=await this.requestLimit(()=>t(d?.id?`${o}/${d.id}`:o,{method:d?.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,l,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=>!r(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]||[],o=this._normalizeToArray(r),m=this._normalizeToArray(n),d=m.filter(t=>!o.includes(t)),u=o.filter(t=>!m.includes(t)),h=d.map(t=>this.server.ldap.queryLimit(async()=>{const e=await this._getDelegateEmail(t);return{dn:t,email:e}})),c=u.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"},l));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};
1
+ import t from"node-fetch";import{TwakePlugin as e}from"../../abstract/twakePlugin.js";import{ConflictError as a}from"../../lib/errors.js";import{asyncHandler as i,escapeLdapFilter as s,isDummyMemberDn as r,normalizeDn as n}from"../../lib/utils.js";import{wantJson as o}from"../../lib/expressFormatedResponses.js";const l=[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=>valuesOf(t??void 0,"member")??[],sameAttr=(t,e)=>t.split(";")[0].toLowerCase()===e.split(";")[0].toLowerCase(),valuesOf=(t,e)=>{if(!t)return;const a=Object.keys(t).filter(t=>sameAttr(t,e));return a.length?a.flatMap(e=>{return null==(a=t[e])?[]:(Array.isArray(a)?a:[a]).map(String);var a}):void 0},memberKey=t=>{try{return n(t)}catch{return t.toLowerCase()}},m=["groupofnames","groupofuniquenames","groupofurls"],isGroup=t=>(Array.isArray(t)?t:t?[t]:[]).some(t=>m.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","configurable"],this.dependencies={onLdapChange:"core/ldap/onChange",ldapGroups:"core/ldap/groups"},this.hooks={ldapaddrequest:async([t,e,a])=>{const i=this.teamMailboxDomain(valuesOf(e,this.mailAttr),valuesOf(e,this.mailboxTypeAttr));return i&&await this.assertMembersAt(t,i,valuesOf(e,"member")??[]),void 0!==a?[t,e,a]:[t,e]},ldapmodifyrequest:async([t,e,a,i])=>(await this.guardTeamMailboxChange(t,e),[t,e,a,i]),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},l);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},l))])}}catch(t){this.logger.debug("Could not fetch aliases for mail change:",t)}const o=n&&this.getDisplayNameFromAttributes(n);o&&await this.updateJamesIdentity(t,s,o,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)),o=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"})),...o.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"},l)})])},onLdapQuotaChange:async(t,e,a,i)=>{if(await this.isGroupDn(t))return;const s=i>0;return this.callWebAdminApi("onLdapQuotaChange",`${this.webadminUrl}/quota/users/${e}/size`,s?"PUT":"DELETE",t,s?i.toString():null,{oldQuota:a,newQuota:i},l)},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"},l))])},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`,i((t,e)=>this.getUserQuota(t,e))),this.logger.info(`James API registered at ${e}/v1/users/:user/quota-usage`)}async getUserQuota(e,a){if(!o(e,a))return;const i=e.params.user;try{const e=s(i),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 ${i} not found`});const o=n[this.mailAttr];if(!o)return void a.status(400).json({error:`User ${i} has no mail attribute`});const l=Array.isArray(o)?String(o[0]):String(o),m=`${this.webadminUrl}/quota/users/${l}`,u=await this.requestLimit(()=>t(m,{method:"GET",headers:this.createHeaders()}));if(!u.ok)return void(404===u.status?a.status(404).json({error:`Quota not found for user ${i}`}):a.status(502).json({error:`Failed to retrieve quota from James: ${u.statusText}`}));const d=await u.json();a.json(d)}catch(t){this.logger.error({plugin:this.name,event:"getUserQuota",username:i,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[this.mailAttr]);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},l)}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},l)));const o=this.getDisplayNameFromAttributes(e);o&&await this.updateJamesIdentity(t,r,o)}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)}}getConfigApiData(){return{teamMailboxAnyDomain:Boolean(this.config.james_team_mailbox_any_domain)}}teamMailboxDomain(t,e){if(this.config.james_team_mailbox_any_domain||"teamMailbox"!==this.getMailboxType({[this.mailboxTypeAttr]:e}))return null;const a=this.attributeToString(t);return a&&this.extractMailDomain(a)?.toLowerCase()||null}async guardTeamMailboxChange(t,e){if(this.config.james_team_mailbox_any_domain)return;const a=[this.mailAttr,this.mailboxTypeAttr,"member"];if(![e.add,e.replace].some(t=>t&&Object.keys(t).some(t=>a.some(e=>sameAttr(t,e)))))return;const i=await this.ldapGetAttributes(t,a);if(!i)return;const after=(t,a=t=>t.toLowerCase())=>((t,e,a,i)=>{let s=valuesOf(t,a)??[];const r=valuesOf(e.add,a);r&&(s=[...s,...r]);const n=valuesOf(e.replace,a);n&&(s=n);const o=e.delete;if(Array.isArray(o))o.some(t=>sameAttr(t,a))&&(s=[]);else{const t=valuesOf(o,a);if(t){const e=new Set(t.map(i));s=e.size?s.filter(t=>!e.has(i(t))):[]}}return s})(i,e,t,a),s=this.teamMailboxDomain(after(this.mailAttr),after(this.mailboxTypeAttr));if(!s)return;const r=new Set((valuesOf(i,"member")??[]).map(memberKey)),n=after("member",memberKey),o=n.filter(t=>!r.has(memberKey(t))),l=s!==this.teamMailboxDomain(valuesOf(i,this.mailAttr),valuesOf(i,this.mailboxTypeAttr));await this.assertMembersAt(t,s,o,l?n.filter(t=>r.has(memberKey(t))):[])}async assertMembersAt(t,e,i,s=[]){const foreign=async t=>(await this.getMemberEmails(t)).filter(t=>this.extractMailDomain(t)?.toLowerCase()!==e),[r,n]=await Promise.all([foreign(i),foreign(s)]);if(0===r.length&&0===n.length)return;const o=r.length?`${r.slice(0,5).join(", ")}${r.length>5?", …":""} `+(r.length>1?"are":"is")+" not":"",l=n.length?"some of the members it holds are not":"";throw new a(`The members of team mailbox ${t} must have an address at ${e}: `+[o,l].filter(Boolean).join(", and "))}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 o=`${this.webadminUrl}/users/${a}/identities`,m=await this.requestLimit(()=>t(`${o}?default=true`,{method:"GET",headers:this.createHeaders()}));let u;if(m.ok)u=pickDefaultIdentity(await m.json());else if(404!==m.status){const t="get_default_identity";if(this.shouldRetry(m.status,l,n)){await this.warnAndWait(r,t,n,m);continue}return void this.logger.error({...r,step:t,...n>1?{attempts:n}:{},http_status:m.status,http_status_text:m.statusText})}const d=await this.generateSignature(e),h=u?.id?{id:u.id,email:u.email,name:i}:{email:a,name:i,sortOrder:0};d?h.htmlSignature=d:Object.assign(h,s);const c=u?.id?"update_identity":"create_identity",g=await this.requestLimit(()=>t(u?.id?`${o}/${u.id}`:o,{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,l,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=>!r(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]||[],o=this._normalizeToArray(r),m=this._normalizeToArray(n),u=m.filter(t=>!o.includes(t)),d=o.filter(t=>!m.includes(t)),h=u.map(t=>this.server.ldap.queryLimit(async()=>{const e=await this._getDelegateEmail(t);return{dn:t,email:e}})),c=d.map(t=>this.server.ldap.queryLimit(async()=>{const e=await this._getDelegateEmail(t);return{dn:t,email:e}})),[g,p]=await Promise.all([Promise.all(h),Promise.all(c)]),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"},l));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};
@@ -139,6 +139,14 @@ When a user's quota changes:
139
139
  5000000000
140
140
  ```
141
141
 
142
+ Removing the quota, or setting it to `0`, deletes the user quota instead:
143
+ James would read `0` as a 0 byte mailbox. The user then gets the domain or
144
+ global quota set in James.
145
+
146
+ ```http
147
+ DELETE /quota/users/jdoe@company.com/size
148
+ ```
149
+
142
150
  ### Forwards
143
151
 
144
152
  Forwards are a setting of the user in Twake Mail, beside "keep a copy in my
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ldap-rest",
3
- "version": "0.21.2",
3
+ "version": "0.21.3",
4
4
  "description": "Light directory manager",
5
5
  "keywords": [
6
6
  "Identity-Manager"