File Coverage

blib/lib/Mail/Alias/LocalFile.pm
Criterion Covered Total %
statement 175 177 98.8
branch 45 50 90.0
condition 13 15 86.6
subroutine 20 20 100.0
pod 11 11 100.0
total 264 273 96.7


line stmt bran cond sub pod time code
1             package Mail::Alias::LocalFile;
2              
3             our $VERSION = '0.01';
4              
5 5     5   1745861 use 5.012;
  5         22  
6 5     5   34 use strict;
  5         10  
  5         162  
7 5     5   29 use warnings;
  5         12  
  5         335  
8 5     5   3250 use Moo;
  5         45296  
  5         32  
9 5     5   13683 use namespace::autoclean;
  5         115567  
  5         22  
10 5     5   3448 use Email::Valid;
  5         619533  
  5         633  
11 5     5   58 use Scalar::Util qw(reftype);
  5         18  
  5         452  
12 5     5   3618 use Types::Standard qw(ArrayRef HashRef Str);
  5         717581  
  5         62  
13 5     5   18003 use Data::Dumper::Concise;
  5         45274  
  5         12723  
14              
15             # Class attributes with type constraints
16              
17             has 'warning' => (
18             is => 'rw',
19             isa => ArrayRef,
20             default => sub { [] },
21             );
22              
23             has 'aliases' => (
24             is => 'ro',
25             isa => HashRef,
26             required => 1,
27             );
28              
29             has 'expanded_addresses' => (
30             is => 'rw',
31             isa => ArrayRef,
32             default => sub { [] },
33             );
34              
35             has 'addresses_and_aliases' => (
36             is => 'rw',
37             isa => ArrayRef,
38             default => sub { [] },
39             );
40              
41             has 'original_input' => (
42             is => 'rw',
43             isa => ArrayRef,
44             default => sub { [] },
45             );
46              
47             has 'processed_aliases' => (
48             is => 'rw',
49             isa => HashRef,
50             default => sub { {} },
51             );
52              
53             has 'uniq_email_addresses' => (
54             is => 'rw',
55             isa => ArrayRef,
56             default => sub { [] },
57             );
58              
59             has 'mta_aliases' => (
60             is => 'rw',
61             isa => ArrayRef,
62             default => sub { [] },
63             );
64              
65             # Methods
66             sub resolve_recipients {
67 45     45 1 5976 my ( $self, $addresses_and_aliases_ref ) = @_;
68              
69             # Initialize all data structures
70 45         995 $self->addresses_and_aliases($addresses_and_aliases_ref);
71 45         1156 my @values = @{$addresses_and_aliases_ref};
  45         142  
72 45         921 $self->original_input( \@values );
73 45         1824 $self->expanded_addresses( [] );
74 45         1897 $self->processed_aliases( {} );
75 45         1799 $self->mta_aliases( [] );
76              
77             # Process all addresses and aliases
78 45         1004 while ( @{ $self->addresses_and_aliases } ) {
  108         2139  
79 63         410 my $item = shift @{ $self->addresses_and_aliases };
  63         1096  
80 63 100       514 if ( $item =~ /^mta_/ ) {
81 11         22 my @warning = @{ $self->warning };
  11         205  
82 11         80 push @warning, "ERROR: Alias keys with 'mta_' prefix are not allowed, skipping alias '$item'";
83 11         29 push @warning, "ERROR: Alias values may contain aliases with the 'mta_' prefix but not keys like '$item'";
84 11         229 $self->warning( \@warning );
85 11         266 next;
86             }
87              
88 52         157 $self->extract_addresses_from_list($item);
89             }
90              
91             # Remove duplicates and build the final comma-separated list
92 45         405 my $uniq_email_recipients = $self->remove_duplicate_email_addresses();
93              
94             # Combine email recipients with MTA aliases
95 45         73 my @all_recipients = ( @{$uniq_email_recipients}, @{$self->mta_aliases} );
  45         74  
  45         845  
96 45         374 my $recipients = join( ',', @all_recipients );
97              
98             # warn if there are no recipients (all were bad email addresses)
99 45 100       146 if ($recipients eq "" ) {
100 17         4289 push @{ $self->warning }, "ERROR, There are no valid email addresses";
  17         341  
101             }
102              
103 45         170 my %result;
104 45         1069 $result{expanded_addresses} = $self->expanded_addresses;
105 45         966 $result{uniq_email_addresses} = $self->uniq_email_addresses;
106 45         321 $result{recipients} = $recipients;
107 45         807 $result{original_input} = $self->original_input;
108 45         323 $result{aliases} = $self->aliases;
109 45         790 $result{processed_aliases} = $self->processed_aliases;
110 45         953 $result{mta_aliases} = $self->mta_aliases;
111              
112 45         303 my $circular_references = $self->detect_circular_references($result{aliases});
113              
114 45         121 $result{circular_references} = $circular_references;
115 45         968 $result{warning} = $self->warning;
116 45         453 return \%result;
117             }
118              
119             sub extract_addresses_from_list {
120 103     103 1 228 my ( $self, $element ) = @_;
121              
122             # Skip empty elements
123 103 100 100     465 return unless defined $element && length $element;
124              
125             # Handle elements that contain multiple items (comma or space separated)
126 97 100 66     369 if ( ( $element =~ /,/ ) || ( $element =~ / / ) ) {
127              
128             # Normalize spaces and commas
129 16         55 my $multi_spaces = qr/\s+/x; # one or more consecutive spaces
130 16         52 my $multi_commas = qr/,+/x; # one or more consecutive commas
131 16         31 my $single_comma = ','; # a single comma
132              
133 16         138 $element =~ s{$multi_spaces}{$single_comma}g;
134 16         108 $element =~ s{$multi_commas}{$single_comma}g;
135              
136             # Split the element into individual items
137 16         52 my @items = split( /,/x, $element );
138 16         36 foreach my $single_item (@items) {
139 39         177 $single_item =~ s/^\s+|\s+$//g; # Trim whitespace
140             # Process each individual item if it's not empty
141 39 100       95 if ( length $single_item ) {
142 38         78 $self->process_single_item($single_item);
143             }
144             }
145             }
146             else {
147             # Process a simple element (not comma/space separated)
148 81         401 $element =~ s/^\s+|\s+$//g; # Trim whitespace
149 81 50       183 if ( length $element ) {
150 81         191 $self->process_single_item($element);
151             }
152             }
153 97         182 return;
154             }
155              
156             sub process_single_item {
157 119     119 1 215 my ( $self, $single_item ) = @_;
158              
159             # Check if this is an MTA-delegated alias (starts with mta_)
160 119 100       371 if ( $single_item =~ /^mta_(.+)$/x ) {
    100          
161 11         30 $self->process_mta_alias($1);
162             }
163             # Process based on whether it looks like an email address
164             elsif ( $single_item =~ /@/x ) {
165 45         105 $self->process_potential_email($single_item);
166             }
167             else {
168 63         159 $self->process_potential_alias($single_item);
169             }
170 119         251 return;
171             }
172              
173             sub process_mta_alias {
174 11     11 1 33 my ( $self, $alias ) = @_;
175            
176             # Add the alias to the list of MTA aliases (without the mta_ prefix)
177 11         18 push @{ $self->mta_aliases }, $alias;
  11         246  
178 11         82 return;
179             }
180              
181             sub process_potential_email {
182 48     48 1 2298 my ( $self, $item ) = @_;
183              
184             # Normalize and validate the email address
185 48         252 $item = lc($item);
186              
187 48         274 my $address = Email::Valid->address($item);
188             # if it was a bad email format, $address is not defined
189 48 100       31022 if ( !defined $address ) {
190 3         6 push @{ $self->warning},
  3         56  
191             "ERROR: $item is not a correctly formatted email address, skipping";
192             }
193             else {
194 45 50       119 if ($address) {
195 45         66 push @{ $self->expanded_addresses }, $address;
  45         1481  
196             }
197             }
198 48         366 return;
199             }
200              
201             sub process_potential_alias {
202 64     64 1 116 my ( $self, $alias ) = @_;
203 64         1341 my $processed_aliases = $self->processed_aliases;
204              
205             # Check if this alias exists
206 64 100       495 if ( !exists $self->aliases->{$alias} ) {
207 13         23 push @{ $self->warning }, "ERROR: The alias $alias was not found, skipping.";
  13         233  
208 13         86 return;
209             }
210              
211             # Check if we've already processed this alias
212 51 100       109 if ( $processed_aliases->{$alias} ) {
213              
214             # Skip it - we've already processed it completely
215             # prevents duplicate inclusion of and alias
216 2         6 return;
217             }
218              
219 49 100 100     191 if ( ( defined reftype( $self->aliases->{$alias} ) )
220             && ( reftype( $self->aliases->{$alias} ) eq 'ARRAY' ) )
221             {
222             # Handle array of values, convert to string of values
223 1         1 my @values = @{ $self->aliases->{$alias} };
  1         5  
224 1         3 my $string = join( ",", @values );
225              
226 1         3 $processed_aliases->{$alias} = 'Processed';
227             }
228             else {
229             # already a string, just use it as the value
230 48         135 $processed_aliases->{$alias} = 'Processed';
231             }
232              
233 49         4706 $self->processed_aliases($processed_aliases);
234              
235             # Expand the alias
236 49         1397 $self->expand_alias($alias);
237 49         102 return;
238             }
239              
240             sub expand_alias {
241 49     49 1 89 my ( $self, $alias ) = @_;
242              
243 49         274 my $alias_items = $self->aliases->{$alias};
244              
245             # Handle different types of alias values
246 49 100 100     154 if ( ( defined reftype($alias_items) )
247             && ( reftype($alias_items) eq 'ARRAY' ) )
248             {
249             # Handle array of values
250 1         2 foreach my $element (@$alias_items) {
251              
252             # Process each element directly to avoid re-adding to the queue
253 2         3 $self->extract_addresses_from_list($element);
254             }
255             }
256             else {
257             # Handle scalar value
258 48 50 66     287 if ( ( $alias_items =~ /,/x ) || ( $alias_items =~ / /x ) ) {
    0          
259              
260             # Multiple items in the scalar value
261 48         119 $self->extract_addresses_from_list($alias_items);
262             }
263             elsif ( $alias_items =~ /@/x ) {
264              
265             # Looks like an email address, validate it
266 0         0 $self->process_potential_email($alias_items);
267             }
268             else {
269             # Probably an alias, process directly
270 0         0 $self->process_potential_alias($alias_items);
271             }
272             }
273 49         80 return;
274             }
275              
276             sub remove_duplicate_email_addresses {
277 45     45 1 116 my ($self) = @_;
278              
279             # Use a hash to track unique addresses
280 45         76 my @uniq_email_addresses;
281             my %found_once;
282              
283 45         62 foreach my $recipient ( @{ $self->expanded_addresses } ) {
  45         979  
284 42 100       222 if ( !exists $found_once{$recipient} ) {
285 40         99 push @uniq_email_addresses, $recipient;
286 40         107 $found_once{$recipient} = 'true';
287             }
288             }
289              
290 45         5671 $self->uniq_email_addresses( \@uniq_email_addresses );
291 45         1283 return \@uniq_email_addresses;
292             }
293              
294             # Function to detect circular references
295             sub detect_circular_references {
296 48     48 1 154 my ($self, $aliases) = @_;
297 48         76 my %seen_paths = ();
298 48         74 my @circular_references = ();
299              
300 48         197 foreach my $key ( keys %$aliases ) {
301             # Skip checking aliases with mta_ prefix
302             # Should not exist, create warning
303 131 100       4469 if ( $key =~ /^mta_/ ) {
304 6         12 my @warning = @{ $self->warning };
  6         119  
305 6         49 push @warning, "ERROR: Alias keys with 'mta_' prefix are not allowed, skipping alias '$key'";
306 6         119 $self->warning( \@warning );
307 6         124 next;
308             }
309              
310 125         251 my @path = ($key);
311 125         285 check_circular( $key, $aliases, \@path, \%seen_paths,
312             \@circular_references );
313             }
314              
315 48 100       121 if ( $circular_references[0] ) {
316 6         12 my @warning = @{ $self->warning };
  6         220  
317 6         69 push @warning, "ERROR: The aliases file contains entries that are circular references";
318 6         156 $self->warning( \@warning );
319             }
320 48         304 return \@circular_references;
321             }
322              
323             # Recursive function to check for circular references
324             sub check_circular {
325 199     199 1 372 my ( $current_key, $aliases, $path, $seen_paths, $circular_references ) = @_;
326 199         325 my $value = $aliases->{$current_key};
327              
328             # If value is a reference to an array, process each element
329 199 100       483 if ( ref($value) eq 'ARRAY' ) {
    100          
330 16         30 foreach my $item (@$value) {
331 44         83 process_item( $item, $aliases, $path, $seen_paths,
332             $circular_references );
333             }
334             }
335              
336             # If value is a scalar (string), process it directly
337             elsif ( !ref($value) ) {
338 181         342 process_item( $value, $aliases, $path, $seen_paths,
339             $circular_references );
340             }
341             }
342              
343             # Process individual items and check for circular references
344             sub process_item {
345 225     225 1 411 my ( $item, $aliases, $path, $seen_paths, $circular_references ) = @_;
346              
347             # Split comma-separated values and trim whitespace
348 225         540 my @items = split( /,/, $item );
349 225         398 foreach my $subitem (@items) {
350 305         1538 $subitem =~ s/^\s+|\s+$//g; # Trim whitespace
351 305 100       628 next unless $subitem; # Skip empty items
352            
353             # Skip items with mta_ prefix
354 293 100       625 next if $subitem =~ /^mta_/x;
355              
356             # Check if this is a reference to another alias
357 278 100       825 if ( exists $aliases->{$subitem} ) {
358              
359             # Check for circular reference
360 104 100       178 if ( grep { $_ eq $subitem } @$path ) {
  180         401  
361              
362             # Found a circular reference
363 30         88 my @circular_path = ( @$path, $subitem );
364 30         135 my $path_str = join( " -> ", @circular_path );
365 30         208 push @$circular_references, $path_str;
366             }
367             else {
368             # Continue tracing the path
369 74         183 my @new_path = ( @$path, $subitem );
370 74         172 check_circular( $subitem, $aliases, \@new_path, $seen_paths,
371             $circular_references );
372             }
373             }
374             }
375             }
376              
377             # Clean up with namespace::autoclean
378             __PACKAGE__->meta->make_immutable;
379              
380             1;
381              
382             =head1 NAME
383              
384             Mail::Alias::LocalFile - A module for resolving email aliases and building recipient lists
385             from a locally maintained aliases file. The MTA shared aliases file may stll be
386             used when and if desired.
387              
388             =head1 SYNOPSIS
389              
390             use Mail::Alias::LocalFile;
391              
392             $resolver = Mail::Alias::LocalFile->new(aliases => $alias_file_href);
393             $result = $resolver->resolve_recipients($intended_recipients_aref);
394              
395             # Get the final comma-separated list of recipients
396             my $recipients = $result->{recipients};
397              
398             # Check for any warnings
399             if (@{$result->{warning}}) {
400             print "Warnings: ", join("\n", @{$result->{warning}}), "\n";
401             }
402              
403              
404             You can also detect all circular references in the local aliases file:
405              
406             $resolver = Mail::Alias::LocalFile->new(aliases => $alias_file_href);
407             $circular = $resolver->detect_circular_references($alias_file_ref);
408             my @circular_references = @{$circular};
409             if ( $circular_references[0] ) {
410             print "Circular references detected: ", join("\n", @circular_references), "\n";
411             }
412              
413             =head1 DESCRIPTION
414              
415             The C module provides functionality to resolve email addresses and aliases into a
416             unique list of email recipients. It handles nested aliases, validates email addresses, and
417             detects circular references in alias definitions.
418              
419             This module is particularly useful for applications that need to expand distribution lists
420             or group aliases into actual email addresses while ensuring uniqueness and validity.
421              
422             Use of the system aliases file when desired is supported via the special alias prefix 'mta_'.
423             Values with the prefix 'mta_' will not be expanded locally but will be passed to the MTA for
424             expansion. The 'mta_' prefix will be stripped before passing to the MTA.
425              
426             Alias keys with the 'mta_' prefix are not allowed and will be skipped with a warning.
427              
428             my $aliases = {
429             'group2' => 'Mary@example.com, Joe@example.com'
430             'system' => 'mta_postmaster',
431             'normal' => 'normal@example.com',
432             };
433              
434             use Mail::Alias::LocalFile;
435              
436             my $resolver = Mail::Alias::LocalFile->new(aliases => $aliases);
437             my $result = $resolver->resolve_recipients([ bill@example.com group2 system ]);
438             my $recipients = $result->{recipients};
439              
440             The recipients email addresses to pass to your email client code is:
441             'bill@example.com,mary@example,com,joe@example.com,postmaster'
442              
443             group2 and mta_postmaster are expanded from the local aliases file
444             and the postmaster alias will expand from the system wide aliases file
445              
446             =head1 OUTPUT
447             Returns a hash_ref:
448              
449             $result{recipients}
450             $result{warning}
451             $result{original_input}
452             $result{aliases}
453             $result{processed_aliases}
454             $result{expanded_addresses}
455             $result{uniq_email_addresses}
456             $result{mta_aliases}
457              
458             Where $result{recipients} is the comma separated expanded email addresses and MTA aliases
459             suitable for use in the To: field of your email code
460              
461             Always check $result{warning} to identify problems encountered, if any.
462             The other available result values are useful for troubleshooting
463              
464             =head1 ATTRIBUTES
465              
466             =head2 warning
467              
468             An array reference storing warning messages generated during processing.
469              
470             $resolver->warning(["Warning message"]);
471             my $warnings = $resolver->warning;
472              
473             =head2 aliases
474              
475             A hash reference mapping alias names to their values (either strings or array
476             references). This attribute is required when creating a new instance. It is
477             provided from your application after your application loads a locally
478             maintained aliases file.
479              
480             my $resolver = Mail::Alias::LocalFile->new(aliases => $aliases);
481             my $aliases = $resolver->aliases;
482              
483             This is how Perl sees your local alias file data for parsing.
484              
485             =head2 expanded_addresses
486              
487             An array reference containing the cumulative expanded email addresses (including
488             duplicates as each item from the input is expanded
489              
490             my $all_addresses = $resolver->expanded_addresses;
491              
492             For troubleshooting if your result is not as expected.
493              
494             =head2 addresses_and_aliases
495              
496             An array reference. A working copy of the original_input that is consumed
497             by shift, to provide each item of the array for analysis.
498              
499              
500             =head2 original_input
501              
502             An array reference containing the original input provided to C.
503              
504             my $result = $resolver->resolve_recipients([ bill@example.com group2 system ]);
505             my $original = $resolver->original_input;
506              
507             Stored for troubleshooting purposes, if needed.
508              
509             =head2 processed_aliases
510              
511             A hash reference tracking which aliases have been processed and used to avoid
512             duplicate processing and suppress circular references (if any).
513              
514             my $processed = $resolver->processed_aliases;
515              
516             =head2 uniq_email_addresses
517              
518             An array reference containing the final list of unique email addresses after
519             expansion and deduplication.
520              
521             my $unique = $resolver->uniq_email_addresses;
522              
523             =head2 mta_aliases
524              
525             An array reference containing aliases that should be passed to the MTA for
526             expansion after the 'mta_' prefix has been removed. Not used unless the local
527             alias has a value containing an alias with the mta_ prefix. The mta_ prefix
528             must be used in order to pass an alias through for expansion by the MTA alias
529             file.
530              
531             my $mta_aliases = $resolver->mta_aliases;
532              
533             =head1 METHODS
534              
535             =head2 resolve_recipients
536              
537             Expands a list of addresses and aliases into a unique list of email addresses.
538              
539             my $result = $resolver->resolve_recipients(['team', 'john@example.com']);
540              
541             Returns a hash reference with the following keys:
542              
543             =over 4
544              
545             =item * C: All expanded addresses (including duplicates)
546              
547             =item * C: Unique email addresses after deduplication
548              
549             =item * C: Comma-separated string of unique addresses and MTA aliases
550              
551             =item * C: Original input provided
552              
553             =item * C: Any warnings generated during processing
554              
555             =item * C: Reference to the original aliases hash
556              
557             =item * C: Aliases that were processed
558              
559             =item * C: Aliases designated to be processed by the MTA
560              
561             =back
562              
563             =head2 extract_addresses_from_list
564              
565             Processes a single element that might contain multiple addresses or aliases.
566              
567             $resolver->extract_addresses_from_list('john@example.com, team');
568              
569             =head2 process_single_item
570              
571             Processes a single item, determining if it's an email address, an alias, or an MTA-delegated alias.
572              
573             $resolver->process_single_item('john@example.com');
574              
575             =head2 process_mta_alias
576              
577             Processes an MTA-delegated alias (with 'mta_' prefix).
578              
579             $resolver->process_mta_alias('postmaster');
580              
581             =head2 process_potential_email
582              
583             Validates and normalizes a potential email address.
584              
585             $resolver->process_potential_email('John@Example.COM');
586              
587             =head2 process_potential_alias
588              
589             Processes an alias name, expanding it if found.
590              
591             $resolver->process_potential_alias('team');
592              
593             =head2 expand_alias
594              
595             Expands an alias into its constituent addresses and/or other aliases.
596              
597             $resolver->expand_alias('team');
598              
599             =head2 remove_duplicate_email_addresses
600              
601             Removes duplicate email addresses from the expanded list.
602              
603             my $unique_addresses = $resolver->remove_duplicate_email_addresses();
604              
605             =head2 detect_circular_references
606              
607             Detects circular references in the alias definitions.
608              
609             my @circular = $resolver->detect_circular_references($aliases);
610              
611             Returns an array of strings describing any circular references found, with each string
612             showing the path of the circular reference (e.g., "team -> dev-team -> team").
613              
614             =head2 check_circular
615              
616             Internal recursive function to check for circular references.
617              
618             =head2 process_item
619              
620             Internal function to process individual items when checking for circular references.
621              
622             =head1 DEPENDENCIES
623              
624             =over 4
625              
626             =item * Moo
627              
628             =item * namespace::autoclean
629              
630             =item * Email::Valid
631              
632             =item * Scalar::Util
633              
634             =item * Data::Dumper::Concise
635              
636             =item * Types::Standard
637              
638             =back
639              
640             =head1 AUTHOR
641              
642             Russ Brewer (RBREW) rbrew@cpan.org
643              
644             =head1 VERSION
645              
646             0.01
647              
648             =head1 SEE ALSO
649              
650             =over 4
651              
652             =item * Email::Valid->address
653              
654             =item * Moo
655              
656             =back
657              
658             =cut