@@ -49,6 +49,14 @@ export type {
4949 PasswordResetRequestedContext ,
5050 TwoFactorContext ,
5151 DeviceInfo ,
52+ // Organization event contexts
53+ OrganizationInvitationSentContext ,
54+ OrganizationInvitationAcceptedContext ,
55+ OrganizationMemberRemovedContext ,
56+ OrganizationRoleChangedContext ,
57+ // Social account event contexts
58+ SocialAccountLinkedContext ,
59+ SocialAccountUnlinkedContext ,
5260} from './types' ;
5361
5462/**
@@ -136,6 +144,17 @@ function resolveOptions(options: InboundEmailPluginOptions): ResolvedPluginOptio
136144 'password-reset-requested' : { enabled : false , ...options . events ?. [ 'password-reset-requested' ] } ,
137145 'two-factor-enabled' : { enabled : true , ...options . events ?. [ 'two-factor-enabled' ] } ,
138146 'two-factor-disabled' : { enabled : true , ...options . events ?. [ 'two-factor-disabled' ] } ,
147+ // Organization events (disabled by default, requires organization plugin)
148+ 'organization-invitation-sent' : { enabled : true , ...options . events ?. [ 'organization-invitation-sent' ] } ,
149+ 'organization-invitation-accepted' : {
150+ enabled : true ,
151+ ...options . events ?. [ 'organization-invitation-accepted' ] ,
152+ } ,
153+ 'organization-member-removed' : { enabled : true , ...options . events ?. [ 'organization-member-removed' ] } ,
154+ 'organization-role-changed' : { enabled : true , ...options . events ?. [ 'organization-role-changed' ] } ,
155+ // Social account events
156+ 'social-account-linked' : { enabled : true , ...options . events ?. [ 'social-account-linked' ] } ,
157+ 'social-account-unlinked' : { enabled : true , ...options . events ?. [ 'social-account-unlinked' ] } ,
139158 } ,
140159 } ;
141160}
@@ -231,6 +250,26 @@ function isNewDevice(userId: string, ipAddress?: string | null, userAgent?: stri
231250 return true ;
232251}
233252
253+ /**
254+ * Format a provider ID to a display name.
255+ * e.g., "google" -> "Google", "github" -> "GitHub"
256+ */
257+ function formatProviderName ( providerId : string ) : string {
258+ const providerNames : Record < string , string > = {
259+ google : 'Google' ,
260+ github : 'GitHub' ,
261+ apple : 'Apple' ,
262+ facebook : 'Facebook' ,
263+ twitter : 'Twitter' ,
264+ discord : 'Discord' ,
265+ microsoft : 'Microsoft' ,
266+ linkedin : 'LinkedIn' ,
267+ spotify : 'Spotify' ,
268+ twitch : 'Twitch' ,
269+ } ;
270+ return providerNames [ providerId . toLowerCase ( ) ] ?? providerId . charAt ( 0 ) . toUpperCase ( ) + providerId . slice ( 1 ) ;
271+ }
272+
234273/**
235274 * Creates a Better Auth plugin that sends transactional emails via Inbound
236275 * for important authentication events.
@@ -442,6 +481,271 @@ export function inboundEmailPlugin(options: InboundEmailPluginOptions): BetterAu
442481 } ) ;
443482 } ,
444483 } ,
484+ // Organization invitation sent handler
485+ {
486+ matcher : ( ctx ) =>
487+ ( ctx . path === '/organization/invite-member' || ctx . path === '/organization/send-invitation' ) &&
488+ ctx . method === 'POST' ,
489+ handler : async ( ctx : unknown ) => {
490+ const context = ctx as {
491+ context ?: {
492+ session ?: { user ?: { email ?: string ; name ?: string ; id ?: string } } ;
493+ returned ?: { status ?: number ; body ?: { inviteLink ?: string ; expiresAt ?: string } } ;
494+ } ;
495+ body ?: {
496+ email ?: string ;
497+ role ?: string ;
498+ organizationId ?: string ;
499+ } ;
500+ } ;
501+
502+ const session = context . context ?. session ;
503+ const returned = context . context ?. returned ;
504+ const body = context . body ;
505+
506+ if ( returned ?. status !== 200 || ! body ?. email || ! body ?. organizationId ) {
507+ return ;
508+ }
509+
510+ // We need to get organization name - for now use ID as fallback
511+ await sendAuthEmail ( resolvedOptions , 'organization-invitation-sent' , {
512+ email : body . email ,
513+ inviterName : session ?. user ?. name ?? null ,
514+ inviterEmail : session ?. user ?. email ?? null ,
515+ organizationName : body . organizationId , // Would be replaced with actual name if available
516+ organizationId : body . organizationId ,
517+ role : body . role ?? 'member' ,
518+ inviteLink : returned . body ?. inviteLink ,
519+ timestamp : new Date ( ) . toISOString ( ) ,
520+ expiresAt : returned . body ?. expiresAt ,
521+ } ) ;
522+ } ,
523+ } ,
524+ // Organization invitation accepted handler
525+ {
526+ matcher : ( ctx ) =>
527+ ( ctx . path === '/organization/accept-invitation' || ctx . path . includes ( '/accept-invite' ) ) &&
528+ ctx . method === 'POST' ,
529+ handler : async ( ctx : unknown ) => {
530+ const context = ctx as {
531+ context ?: {
532+ newSession ?: { user ?: { email ?: string ; name ?: string ; id ?: string } } ;
533+ returned ?: {
534+ status ?: number ;
535+ body ?: {
536+ organization ?: { id ?: string ; name ?: string } ;
537+ member ?: { role ?: string } ;
538+ } ;
539+ } ;
540+ } ;
541+ } ;
542+
543+ const newSession = context . context ?. newSession ;
544+ const returned = context . context ?. returned ;
545+
546+ if ( returned ?. status !== 200 || ! newSession ?. user ?. email ) {
547+ return ;
548+ }
549+
550+ const org = returned . body ?. organization ;
551+ if ( ! org ?. id ) {
552+ return ;
553+ }
554+
555+ // Note: In a real implementation, you'd fetch the org admins to notify
556+ // For now, this sends to the user who accepted
557+ await sendAuthEmail ( resolvedOptions , 'organization-invitation-accepted' , {
558+ email : newSession . user . email ,
559+ name : newSession . user . name ?? null ,
560+ userId : newSession . user . id ?? '' ,
561+ organizationName : org . name ?? org . id ,
562+ organizationId : org . id ,
563+ role : returned . body ?. member ?. role ?? 'member' ,
564+ timestamp : new Date ( ) . toISOString ( ) ,
565+ notifyEmail : newSession . user . email , // Would be org admin email in production
566+ } ) ;
567+ } ,
568+ } ,
569+ // Organization member removed handler
570+ {
571+ matcher : ( ctx ) =>
572+ ( ctx . path === '/organization/remove-member' || ctx . path . includes ( '/remove-member' ) ) &&
573+ ctx . method === 'POST' ,
574+ handler : async ( ctx : unknown ) => {
575+ const context = ctx as {
576+ context ?: {
577+ session ?: { user ?: { email ?: string ; name ?: string ; id ?: string } } ;
578+ returned ?: {
579+ status ?: number ;
580+ body ?: {
581+ member ?: { email ?: string ; name ?: string ; id ?: string } ;
582+ organization ?: { id ?: string ; name ?: string } ;
583+ } ;
584+ } ;
585+ } ;
586+ } ;
587+
588+ const session = context . context ?. session ;
589+ const returned = context . context ?. returned ;
590+
591+ if ( returned ?. status !== 200 ) {
592+ return ;
593+ }
594+
595+ const member = returned . body ?. member ;
596+ const org = returned . body ?. organization ;
597+
598+ if ( ! member ?. email || ! org ?. id ) {
599+ return ;
600+ }
601+
602+ await sendAuthEmail ( resolvedOptions , 'organization-member-removed' , {
603+ email : member . email ,
604+ name : member . name ?? null ,
605+ userId : member . id ?? '' ,
606+ organizationName : org . name ?? org . id ,
607+ organizationId : org . id ,
608+ removedByName : session ?. user ?. name ?? null ,
609+ timestamp : new Date ( ) . toISOString ( ) ,
610+ } ) ;
611+ } ,
612+ } ,
613+ // Organization role changed handler
614+ {
615+ matcher : ( ctx ) =>
616+ ( ctx . path === '/organization/update-member-role' || ctx . path . includes ( '/update-role' ) ) &&
617+ ctx . method === 'POST' ,
618+ handler : async ( ctx : unknown ) => {
619+ const context = ctx as {
620+ context ?: {
621+ session ?: { user ?: { email ?: string ; name ?: string ; id ?: string } } ;
622+ returned ?: {
623+ status ?: number ;
624+ body ?: {
625+ member ?: { email ?: string ; name ?: string ; id ?: string } ;
626+ organization ?: { id ?: string ; name ?: string } ;
627+ previousRole ?: string ;
628+ newRole ?: string ;
629+ } ;
630+ } ;
631+ } ;
632+ body ?: { role ?: string } ;
633+ } ;
634+
635+ const session = context . context ?. session ;
636+ const returned = context . context ?. returned ;
637+
638+ if ( returned ?. status !== 200 ) {
639+ return ;
640+ }
641+
642+ const member = returned . body ?. member ;
643+ const org = returned . body ?. organization ;
644+
645+ if ( ! member ?. email || ! org ?. id ) {
646+ return ;
647+ }
648+
649+ await sendAuthEmail ( resolvedOptions , 'organization-role-changed' , {
650+ email : member . email ,
651+ name : member . name ?? null ,
652+ userId : member . id ?? '' ,
653+ organizationName : org . name ?? org . id ,
654+ organizationId : org . id ,
655+ previousRole : returned . body ?. previousRole ?? 'member' ,
656+ newRole : returned . body ?. newRole ?? context . body ?. role ?? 'member' ,
657+ changedByName : session ?. user ?. name ?? null ,
658+ timestamp : new Date ( ) . toISOString ( ) ,
659+ } ) ;
660+ } ,
661+ } ,
662+ // Social account linked handler
663+ {
664+ matcher : ( ctx ) =>
665+ ( ctx . path === '/link-social' ||
666+ ctx . path === '/callback' ||
667+ ctx . path . includes ( '/link-social-account' ) ) &&
668+ ctx . method === 'POST' ,
669+ handler : async ( ctx : unknown ) => {
670+ const context = ctx as {
671+ context ?: {
672+ session ?: { user ?: { email ?: string ; name ?: string ; id ?: string } } ;
673+ returned ?: {
674+ status ?: number ;
675+ body ?: {
676+ provider ?: string ;
677+ providerAccountId ?: string ;
678+ linked ?: boolean ;
679+ } ;
680+ } ;
681+ } ;
682+ } ;
683+
684+ const session = context . context ?. session ;
685+ const returned = context . context ?. returned ;
686+
687+ // Only proceed if this was a successful link operation
688+ if ( returned ?. status !== 200 || ! returned . body ?. linked || ! session ?. user ?. email ) {
689+ return ;
690+ }
691+
692+ const providerId = returned . body . provider ;
693+ if ( ! providerId ) {
694+ return ;
695+ }
696+
697+ await sendAuthEmail ( resolvedOptions , 'social-account-linked' , {
698+ email : session . user . email ,
699+ name : session . user . name ?? null ,
700+ userId : session . user . id ?? '' ,
701+ providerName : formatProviderName ( providerId ) ,
702+ providerId,
703+ providerAccountId : returned . body . providerAccountId ,
704+ timestamp : new Date ( ) . toISOString ( ) ,
705+ } ) ;
706+ } ,
707+ } ,
708+ // Social account unlinked handler
709+ {
710+ matcher : ( ctx ) =>
711+ ( ctx . path === '/unlink-social' || ctx . path . includes ( '/unlink-social-account' ) ) &&
712+ ctx . method === 'POST' ,
713+ handler : async ( ctx : unknown ) => {
714+ const context = ctx as {
715+ context ?: {
716+ session ?: { user ?: { email ?: string ; name ?: string ; id ?: string } } ;
717+ returned ?: {
718+ status ?: number ;
719+ body ?: {
720+ provider ?: string ;
721+ } ;
722+ } ;
723+ } ;
724+ body ?: { providerId ?: string } ;
725+ } ;
726+
727+ const session = context . context ?. session ;
728+ const returned = context . context ?. returned ;
729+
730+ if ( returned ?. status !== 200 || ! session ?. user ?. email ) {
731+ return ;
732+ }
733+
734+ const providerId = returned . body ?. provider ?? context . body ?. providerId ;
735+ if ( ! providerId ) {
736+ return ;
737+ }
738+
739+ await sendAuthEmail ( resolvedOptions , 'social-account-unlinked' , {
740+ email : session . user . email ,
741+ name : session . user . name ?? null ,
742+ userId : session . user . id ?? '' ,
743+ providerName : formatProviderName ( providerId ) ,
744+ providerId,
745+ timestamp : new Date ( ) . toISOString ( ) ,
746+ } ) ;
747+ } ,
748+ } ,
445749 ] ,
446750 } ,
447751 } ;
0 commit comments