aboutsummaryrefslogblamecommitdiffstats
path: root/contrib/import_majordomo_into_mailman.pl
blob: e157ce24f7935b1a4576569d894d5c0a5fc44a61 (plain) (tree)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
1289
1290
1291
1292
1293
1294
1295
1296
1297
1298
1299
1300
1301
1302
1303
1304
1305
1306
1307
1308
1309
1310
1311
1312
1313
1314
1315
1316
1317
1318
1319
1320
1321
1322
1323
1324
1325
1326
1327
1328
1329
1330
1331
1332
1333
1334
1335
1336
1337
1338
1339
1340
1341
1342
1343
1344
1345
1346
1347
1348
1349
1350
1351
1352
1353
1354
1355
1356
1357
1358
1359
1360
1361
1362
1363
1364
1365
1366
1367
1368
1369
1370
1371
1372
1373
1374
1375
1376
1377
1378
1379
1380
1381
1382
1383
1384
1385
1386
1387
1388
1389
1390
1391
1392
1393
1394
1395
1396
1397
1398
1399
1400
1401
1402
1403
1404
1405
1406
1407
1408
1409
1410
1411
1412
1413
1414
1415
1416
1417
1418














































































































































































































































































































































































































































































































































































































































































































































































































































































































































                                                                                
                                             

























































































































































































































































































































































































































































































































                                                                                      
#!/usr/bin/perl

#
# Create Mailman list(s) from Majordomo list configuration files.
#
# main() is fully commented and provides a good outline of this script.
#
# LIMITATIONS:
#  - Archives are not currently handled.
#  - A few Majordomo configuration values are ignored (documented in the comment
#    above the getMailmanConfig() function) because they are either inactive,
#    system/constant settings, or don't tranlsate into Mailman.
#  - This script was tested against Majordomo 1.94.4/5 and Mailman 2.1.14-1.
#    Different versions of Majordomo or Mailman may not work with this script.
#    However, some legacy settings for Majordomo are handled.
#
# REQUIREMENTS/ASSUMPTIONS:
#  - Mailman is installed so that its bin/* scripts can be called.
#  - Majordomo has all of its list configurations in a single, local directory.
#  - Majordomo's aliases file exists locally.
#  - $DOMO_INACTIVITY_LIMIT set to zero or the output of Majordomo's
#    consistency_check
#    command is stored locally.
#  - Run this script as root.
#
# BEFORE RUNNING THIS SCRIPT:
#  - Change the "ENVIRONMENT-SPECIFIC VALUES" below to match your system.
#  - It is recommended to run this script with the --stats option first to get
#    a sense of your data. Fields with many 'other' or 'no value' values, or 
#    fields that don't get imported (e.g. message_headers) that have many
#    'has value' values probably need to be considered more closely.
#
# TODO: IMPORT ARCHIVE OPTION
#  - One solution: get all archives inot a 'Unix mbox' file and then use the
#    bin/arch tool. bin/cleanarch can sanity check the mbox before running
#    bin/arch.
#

use strict;
use warnings;

use Getopt::Long;
use Log::Handler;
use File::Temp qw(tempfile);
use Email::Simple;
use Email::Sender::Simple qw(try_to_sendmail);
use Data::Dump qw(dump);


#----------------------- ENVIRONMENT-SPECIFIC VALUES --------------------------#
my $DOMO_PATH              = '/opt/majordomo';
my $DOMO_LIST_DIR          = "$DOMO_PATH/lists";
my $MM_PATH                = '/usr/local/mailman';
my $DOMO_ALIASES           = "$MM_PATH/majordomo/aliases";
my $DOMO_CHECK_CONSISTENCY = "$MM_PATH/majordomo/check_consistency.txt";
my $BOUNCED_OWNERS         = "/opt/mailman-2.1.14-1/uo/majordomo/" .
                             "email_addresses_that_bounced.txt";
my $TMP_DIR                = '/tmp';
# Only import lists that have been active in the last N days.
my $DOMO_INACTIVITY_LIMIT  = 548;   # Optional.  548 days = 18 months.
# If set, overwrite Majordomo's "resend_host" and thus Mailman's "host_name".
my $NEW_HOSTNAME           = '';  # Optional
my $LANGUAGE               = 'en';  # Preferred language for all Mailman lists
my $MAX_MSG_SIZE           = 20000;  # In KB. Used for the Mailman config.
#------------------------------------------------------------------------------#

#
# Global constants
#
my $MM_LIST_DIR    = "$MM_PATH/lists";
my $MM_LIST_LISTS  = "$MM_PATH/bin/list_lists";
my $MM_NEWLIST     = "$MM_PATH/bin/newlist";
my $MM_CONFIGLIST  = "$MM_PATH/bin/config_list";
my $MM_ADDMEMBERS  = "$MM_PATH/bin/add_members";
my $MM_CHECK_PERMS = "$MM_PATH/bin/check_perms";
my $SCRIPT_NAME    = $0 =~ /\/?(\b\w+\b)\.pl$/ ? $1 : '<script name>';
my $LOG_FILE       = "$TMP_DIR/$SCRIPT_NAME.log";

#
# Global namespace
#
my $log = Log::Handler->new();
my $domoStats = {
   'general_stats' => {
      'Lists without owner in aliases' => 0,
      'Total lists' => 0
    }
};


#
# Main program execution
#
main();


#
# Functions
#
sub main {
   # Verify the environment.
   preImportChecks();
   # Get the CLI options.
   my $opts = getCLIOpts();
   # Set up logging.
   addLogHandler($opts);
   # Get lists to import.
   my @domoListNames = getDomoListsToImport($opts);
   # Get a mapping of list names to list owners.
   my $listOwnerMap = getListToOwnerMap();
   # Get lists that already exist in Mailman.
   my %existingLists = getExistingLists();
   # Get all lists that have been inactive longer than the specified limit.
   my $inactiveLists = getInactiveLists();

   if ($opts->{'email_notify'}) {
      # Email Majordomo list owners about the upcoming migration to Mailman.
      sendCustomEmailsToDomoOwners($listOwnerMap, $inactiveLists, 1);
      exit;
   }

   if ($opts->{'email_test'}) {
      # Email Majordomo list owners about the upcoming migration to Mailman.
      sendCustomEmailsToDomoOwners($listOwnerMap, $inactiveLists);
      exit;
   }

   # Iterate through every list, collecting stats, and possibly importing.
   for my $listName (@domoListNames) {
      $log->info("Starting list $listName...");

      if (not exists $listOwnerMap->{$listName}) {
         $log->warning("List $listName has no owner in aliases. Skipping...");
         $domoStats->{'general_stats'}->{'Lists without owner in aliases'} += 1;
         next;
      }

      # Don't import lists that are pending deletion. This is a University of
      # Oregon customization, but it should be harmless for others.
      if (-e "$DOMO_LIST_DIR/$listName.pendel") {
         $log->info("List $listName has a .pendel file. Skipping...");
         $domoStats->{'general_stats'}->{'Lists pending deletion'} += 1;
         next;
      }

      # Don't import the list if it's been inactive beyond the specified limit.
      if (exists $inactiveLists->{$listName}) {
         $log->notice("List $listName has been inactive for " .
                      "$inactiveLists->{$listName} days. Skipping...");
         $domoStats->{'general_stats'}->{'Lists inactive for more than ' .
                                         "$DOMO_INACTIVITY_LIMIT days"} += 1;
         next;
      }

      # Get the Majordomo configuration.
      $log->info("Getting Majordomo config for list $listName...");
      my %domoConfig = getDomoConfig($listName, $listOwnerMap);
      if (not %domoConfig) {
         $log->debug("No config returned by getDomoConfig(). Skipping...");
         next;
      }

      # Add this list to the stats data structure and then skip if --stats.
      $log->debug("Appending this list's data into the stats structure...");
      appendDomoStats(%domoConfig);
      if ($opts->{'stats'}) {
         next;
      }

      # Don't import this list if it already exists in Mailman.
      if (exists $existingLists{$listName}) {
         $log->notice("$listName already exists. Skipping...");
         next;
      }

      # Get a hash of Mailman config values mapped from Majordomo.
      my %mmConfig = getMailmanConfig(%domoConfig);

      # Create the template configuration file for this list.
      my $mmConfigFilePath =
         createMailmanConfigFile($domoConfig{'approve_passwd'}, %mmConfig);

      # Create the Mailman list.
      createMailmanList($listName,
                        $mmConfig{'owner'},
                        $domoConfig{'admin_passwd'});

      # Apply the configuration template to the list.
      configureMailmanList($listName, $mmConfigFilePath);

      # Add members to the list.
      if ($opts->{'subscribers'}) {
         # Create files of digest and non-digest member emails to be used
         # when calling Mailman's bin/config_list.
         my $membersFilePath =
            createMailmanMembersList($domoConfig{'subscribers'});
         my $digestMembersFilePath =
            createMailmanMembersList($domoConfig{'digest_subscribers'});

         # Subscribe the member emails to the Mailman list.
         if ($membersFilePath or $digestMembersFilePath) {
            addMembersToMailmanList($listName,
                                    $membersFilePath,
                                    $digestMembersFilePath);
         }
      }
   }

   # Output stats if requested or else the resuls of the import.
   if ($opts->{'stats'}) {
      printStats();
   } else {
      cleanUp();
      print "Import complete!  " .
            "$domoStats->{'general_stats'}->{'Total lists'} lists imported.\n";
   }
   print "Complete log: $LOG_FILE\n";
}

# Environment/system/setting checks before modifying state
sub preImportChecks {
   # User "mailman" is required because of various calls to the mailman/bin/*
   # scripts.
   my $script_executor = $ENV{LOGNAME} || $ENV{USER} || getpwuid($<);
   if ($script_executor !~ /^(mailman|root)$/) {
      die "Error: Please run this script as user mailman (or root).\n";
   }
   # Check that the Majordomo and Mailman list directories exist.
   for my $dir ($DOMO_LIST_DIR, $MM_LIST_DIR) {
      if (not $dir or not -d $dir) {
         die "Error: Lists directory does not exist: $dir\n";
      }
   }
   # Check that the Mailman binaries exist.
   for my $bin ($MM_LIST_LISTS, $MM_NEWLIST, $MM_CONFIGLIST, $MM_ADDMEMBERS) {
      if (not $bin or not -e $bin) {
         die "Error: Mailman binary doesn't exist: $bin\n";
      }
   }
   # Check the path of $DOMO_CHECK_CONSISTENCY.
   if ($DOMO_CHECK_CONSISTENCY and not -e $DOMO_CHECK_CONSISTENCY) {
      die "Error: \$DOMO_CHECK_CONSISTENCY does not exist: " .
          "$DOMO_CHECK_CONSISTENCY\nCorrect the value or set it to ''.\n";
   }
   # If $DOMO_CHECK_CONSISTENCY exists, then so must $DOMO_ACTIVITY_LIMIT.
   if ($DOMO_CHECK_CONSISTENCY and not $DOMO_INACTIVITY_LIMIT) {
      die "Error: \$DOMO_CHECK_CONSISTENCY exists but " .
          "\$DOMO_INACTIVITY_LIMIT does not.\nPlease set this value.\n";
   }
   # $LANGUAGE must be present and should only contain a-z.
   if (not $LANGUAGE or $LANGUAGE !~ /[a-z]+/i) {
      die "Error: \$LANGUAGE was not set or invalid: $LANGUAGE\n";
   }
   # $MAX_MSG_SIZE must be present and should really be above a minimum size.
   if (not $MAX_MSG_SIZE or $MAX_MSG_SIZE < 5) {
      die "Error: \$MAX_MSG_SIZE was not set or less than 5KB: $MAX_MSG_SIZE\n";
   }
}

# Get CLI options.
sub getCLIOpts {
   my $opts = {};
   GetOptions('list=s'       => \$opts->{'list'},
              'all'          => \$opts->{'all'},
              'subscribers'  => \$opts->{'subscribers'},
              'stats'        => \$opts->{'stats'},
              'email-notify' => \$opts->{'email_notify'},
              'email-test'   => \$opts->{'email_test'},
              'loglevel=s'   => \$opts->{'loglevel'},
              'help'         => \$opts->{'help'},
   );

   if ($opts->{'help'}) {
      help();
   }

   # If --all or --list was not specified, get stats for all lists.
   if (($opts->{'stats'} or $opts->{'email_notify'} or $opts->{'email_test'})
       and not ($opts->{'all'} or $opts->{'list'})) {
      $opts->{'all'} = 1;
   }

   # Validate --loglevel.
   if ($opts->{'loglevel'}) {
      if ($opts->{'loglevel'} !~ /^(debug|info|notice|warning|error)$/) {
         print "ERROR: invalid --loglevel value: $opts->{'loglevel'}\n";
         help();
      }
   } else {
      $opts->{'loglevel'} = 'error';
   }

   return $opts;
}

sub addLogHandler {
   my $opts = shift;
   $log->add(file   => { filename => $LOG_FILE,
                         #mode     => 'trunc',
                         maxlevel => 'debug',
                         minlevel => 'emerg' },
             screen => { log_to   => 'STDOUT',
                         maxlevel => $opts->{'loglevel'},
                         minlevel => 'emerg' }
   );
}

# Return an array of all list names in Majordomo that have a <list>.config file.
sub getDomoListsToImport {
   my $opts = shift;
   my @domoListNames = ();
   # If only one list was specified, validate and return that list.
   if ($opts->{'list'}) {
      my $listConfig = $opts->{'list'} . '.config';
      my $listPath = "$DOMO_LIST_DIR/$listConfig";
      if (not -e $listPath) {
         $log->die(crit => "Majordomo list config does not exist: $listPath");
      }
      @domoListNames = ($opts->{'list'});
   # If all lists were requested, grab all list names from .config files in the
   # $DOMO_LIST_DIR, ignoring digest lists (i.e. *-digest.config files).
   } elsif ($opts->{'all'}) {
      $log->info("Collecting all Majordomo list config files...");
      opendir DIR, $DOMO_LIST_DIR or
         $log->die("Can't open dir $DOMO_LIST_DIR: $!");
      # Don't get digest lists because these are not separate lists in Mailman.
      @domoListNames = grep !/\-digest$/,
                       map { /^([a-zA-Z0-9_\-]+)\.config$/ }
                       readdir DIR;
      closedir DIR;
      if (not @domoListNames) {
         $log->die(crit => "No Majordomo configs found in $DOMO_LIST_DIR");
      }
   # If we're here, --list or --all was not used, so exit.
   } else {
      $log->error("--list=NAME or --all was not used. Nothing to do.");
      help();
   }
   return @domoListNames;
}

# Find all list owners from aliases and create a map of lists to aliases.
sub getListToOwnerMap {
   my %listOwnerMap = ();
   open ALIASES, $DOMO_ALIASES or $log->die("Can't open $DOMO_ALIASES: $!");
   while (my $line = <ALIASES>) {
      if ($line =~ /^owner\-([^:]+):\s*(.*)$/) {
         my ($listName, $listOwners) = (strip($1), strip($2));
         # Some lists in Majordomo's aliases file have the same email listed
         # twice as the list owner (e.g. womenlaw).
         # Converting the listed owners into a hash prevents duplicates.
         my %ownersHash = map { $_ => 1 } split /,/, $listOwners;
         $listOwnerMap{$listName} =
            "'" . (join "', '", keys %ownersHash) . "'";
      }
   }
   close ALIASES or $log->die("Can't close $DOMO_ALIASES: $!");

   return \%listOwnerMap;
}

# Return a hash of all lists that already exist in Mailman.
sub getExistingLists {
   my $cmd = "$MM_LIST_LISTS -b";
   $log->debug("Calling $cmd...");
   my %lists = map { strip($_) => 1 } `$cmd` or $log->die("Command failed: $!");
   return %lists;
}

# By parsing the output of Majordomo's "consistency_check" command, get a list
# of all Majordomo lists inactive beyond the specified $DOMO_INACTIVITY_LIMIT.
sub getInactiveLists {
   my %lists = ();
   if ($DOMO_CHECK_CONSISTENCY) {
      for my $line (split /\n/, getFileTxt($DOMO_CHECK_CONSISTENCY)) {
           
         if ($line =~ /(\S+) has been inactive for (\d+) days/) {
            if ($2 >= $DOMO_INACTIVITY_LIMIT) {
               $lists{$1} = $2;
            }
         }
      }
   }

   return \%lists;
}

sub getBouncedOwners {
   my @bouncedOwners = ();
   for my $line (split /\n/, getFileTxt($BOUNCED_OWNERS)) {
      if ($line =~ /Failed to send mail to (\S+)\./) {
         push @bouncedOwners, $1;
      }
   }

   return @bouncedOwners;
}

sub getOwnerListMap {
   my ($listOwnerMap, $inactiveLists) = @_;
   my $ownerListMap = {};

   for my $list (keys %$listOwnerMap) {
      for my $owner (split /,/, $listOwnerMap->{$list}) {
         my $type = exists $inactiveLists->{$list} ? 'inactive' : 'active';
         my $owner = strip($owner, { full => 1 });
         push @{$ownerListMap->{$owner}->{$type}}, $list;
      }
   }

   return $ownerListMap;
}

# Send an individualized email to each Majordomo list owner that details
# the upcoming migration procedure and their active and inactive lists.
sub sendCustomEmailsToDomoOwners {
   my ($listOwnerMap, $inactiveLists, $notify) = @_;
   $notify = 0 if not defined $notify;

   print "Send email to all Majordomo list owners?\n";
   my $answer = '';
   while ($answer !~ /^(yes|no)$/) {
      print "Please type 'yes' or 'no':  ";
      $answer = <>;
      chomp $answer;
   }

   if ($answer ne "yes") {
      print "No emails were sent.\n";
      return;
   }

   # Create the body of the email for each owner
   my $ownerListMap = getOwnerListMap($listOwnerMap, $inactiveLists);
   for my $owner (keys %$ownerListMap) {
      my $body = $notify ? getEmailNotifyText('top') : getEmailTestText('top');

      # Append the active and inactive lists section to the email body
      my $listsTxt = '';
      for my $listType (qw(active inactive)) {
         if ($ownerListMap->{$owner}->{$listType} and
             @{$ownerListMap->{$owner}->{$listType}}) {
            $listsTxt .= $notify ?
                         getEmailNotifyText($listType,
                            $ownerListMap->{$owner}->{$listType},
                            $inactiveLists) :
                         getEmailTestText($listType,
                            $ownerListMap->{$owner}->{$listType},
                            $inactiveLists);
         }
      }

      if (not $listsTxt) {
         $log->warning("No active or inactive lists found for owner $owner");
         next;
      }

      $body .= $listsTxt;
      $body .= $notify ? getEmailNotifyText('bottom') :
                         getEmailTestText('bottom');

      # Create and send the email.
      my $email = Email::Simple->create(
         header => [ 'From'     => 'listmaster@lists-test.uoregon.edu',
                     'To'       => $owner,
                     'Reply-To' => 'listmaster@lists.uoregon.edu',
                     'Subject'  => 'Mailman (Majordomo replacement) ready ' .
                                   'for testing' ],
         body   => $body,
      );
      $log->debug("Sending notification email to $owner...");
      my $result = try_to_sendmail($email);
      if (not defined $result) {
         $log->notice("Failed to send mail to $owner.");
      }
   }
}

# Return various sections, as requested, of a notification email for
# current Majordomo list owners.
sub getEmailNotifyText {
   my ($section, $lists, $inactiveLists) = @_;

   if ($section eq 'top') {
      return <<EOF;
Greetings;

You are receiving this email because you have been identified as the owner
of one or more Majordomo lists. We're in the process of informing all
current Majordomo list owners of an impending change in the list
processing software used by the University.

Majordomo, the current list processing software in use by the University,
hasn't been updated by its developers since January of 2000. The versions
of the underlying software as well as the operating system required by
Majordomo are no longer supported or maintained. As a result, we are
implementing a new list processing software for the UO campus.

Mailman (http://www.gnu.org/software/mailman/index.html) has been
identified as a robust replacement for Majordomo. Mailman has a Web-based
interface as well as a set of commands issued through email. There is a
wide array of configuration options for the individual lists. Connections
to the Mailman services through the Web are secured with SSL, and
authentication into the server will be secured with LDAP, tied to the
DuckID information.

Information Services is currently in the process of configuring the
Mailman server and testing the list operations. Our plan is to have the
service ready for use very soon, with existing lists showing activity
within the last 18 months being migrated seamlessly onto the new service.

EOF
   } elsif ($section eq 'active') {
      my $listTxt = join "\n", sort @$lists;
      return <<EOF;
Our records indicate that you are the owner of the following lists:

$listTxt

These lists have had activity in the last 18 months, indicating that they
may still be active. Unless you contact us, these active lists will be
automatically migrated to Mailman.

If you no longer want to keep any of the lists shown above, please send
email to email indicating the name(s) of the list, and
that you'd like the list(s) ignored during our migration procedure. You
could also go to the Web page http://lists.uoregon.edu/delap.html and delete the
list prior to the migration.

EOF
   } elsif ($section eq 'inactive') {
      my $listTxt = join "\n",
                    map { my $years = int($inactiveLists->{$_} / 365);
                          $years = $years == 1 ? "$years year" : "$years years";
                          my $days = $inactiveLists->{$_} % 365;
                          $days = $days == 1 ? "$days day" : "$days days";
                          "$_ (inactive $years, $days)" } sort @$lists;
      return <<EOF;
The following lists, for which you are the owner, have had no activity in
the last 18 months:

$listTxt

Lists with no activity within the last 18 months will not be migrated, and
will be unavailable after the migration to Mailman.

If you want to retain one of the lists detailed above, you can either send
a message to the list (which will update its activity), or send an email
to email with an explanation as to why this
inactive list should be migrated and maintained.

EOF
   } elsif ($section eq 'bottom') {
      return <<EOF;
There should be only brief interruptions in service during the migration.
The change to Mailman will be scheduled to take place during the standard
Tuesday maintenance period between 5am and 7am. If your list has been
migrated to Mailman, you and your subscribers will be able to send posts
to your list with the same address, and those messages will be distributed
in the same way that Majordomo would.

Documentation on using Mailman is in development, and will be available to
facilitate the list migration. We will be making a test environment
available to the list owners prior to the migration to the production
Mailman server, so that you can see the settings for your lists and become
accustomed to the new Web interface.

If you have any questions or concerns, please contact email,
or email me directly at email.
   }

   return '';
}

sub getEmailTestText {
   my ($section, $lists, $inactiveLists) = @_;

   if ($section eq 'top') {
      return <<EOF;
Greetings;

Information Services is in the process of configuring Mailman as the UO list
processing software, replacing Majordomo. You're receiving this email because
you have been identified as the owner of one or more lists, either active or
inactive.

The final migration from Majordomo to Mailman is scheduled to take place on
April 27th, 2012. At that time, your active lists will be migrated to the new
service, and lists.uoregon.edu will begin using Mailman instead of Majordomo
for its list processing. This move should be seamless with no downtime.

We have set up a test server and would greatly appreciate your feedback.
Specifically, we'd like to know:
- how well the Majordomo settings for your lists were translated into Mailman
- whether Mailman works as you'd expect and, if not, what didn't work
- any other thoughts, concerns, responses, etc you have

The test server is at https://domain. You will encounter a
browser warning about an "unsigned certificate".  This is expected: please
add an exception for the site.  Once Mailman goes live, however, there will
be a signed certificate in place and this issue will not exist.

All of your Majordomo list settings were migrated onto the Mailman test server
except for subscribers.  We did not import subscribers so that you could test
the email functionality of Mailman for your list.  To do so, subscribe your
email address to your list, and perhaps a few other people who might be
interested in testing Mailman (fellow moderators?), and send off a few emails.

The links to your specific Mailman lists are listed below.  Please feel free
to change anything you want: it is a test server, after all, and we'd love
for you to thoroughly test Mailman.  Anything you change, however, will not
show up on the production server when it goes live.  The production server
will create your Majordomo lists in Mailman based on the Majordomo settings
for your list on April 27, 2012.

The password for these lists will be the same as the adminstrative password
used on the Majordomo list. If you forgot that password, email
email and we will reply with the owner information for
your list, including passwords and a subset of the current list configuration.
EOF
   } elsif ($section eq 'active') {
      my $listTxt = join "\n",
                    map { "https://domain/mailman/listinfo/$_" }
                    sort @$lists;
      return <<EOF;

----

Here is the list of your active lists:

$listTxt

EOF
   } elsif ($section eq 'inactive') {
      my $listTxt = join "\n",
                    map { my $years = int($inactiveLists->{$_} / 365);
                          $years = $years == 1 ? "$years year" : "$years years";
                          my $days = $inactiveLists->{$_} % 365;
                          $days = $days == 1 ? "$days day" : "$days days";
                          "$_ (inactive $years, $days)" } sort @$lists;
      return <<EOF;
----

Here is the list of your inactive lists (no activity in the last 18 months):

$listTxt

Lists with no activity within the last 18 months will not be migrated, and
will be unavailable after the migration to Mailman.

If you want to retain one of the lists detailed above, you can either send
a message to the list (which will update its activity), or send an email
to email with an explanation as to why this
inactive list should be migrated and maintained.

----

EOF
   } elsif ($section eq 'bottom') {
      return <<EOF;
We are continuing to develop documentation for Mailman, which you can access
through the IT Web site. That documentation is still a work in progress;
the final versions have yet to be published. User and administrator guides for
Mailman can be found at http://it.uoregon.edu/mailman. We'll provide further
UO-specific documentation as it becomes available.

It is our intention that this transition proceed smoothly, with a minimum of
disruption in services for you. Please report any problems or concerns to
email, and we will address your concerns as quickly as
possible.
EOF
   }
}


# Parse all text configuration files for a Majordomo list and return that
# info in the %config hash with fields as keys and field values
# as hash values.  Example: {'subscribe_policy' => 'open'}.
# Note that every text configuration file is parsed, not just <listname>.config.
# So, for example, <listname>.post is added to %config as
# {'restrict_post_emails': 'email1,email2,...'}. The following files
# are examined: listname, listname.info, listname.intro, listname.config,
# listname.post, listname.moderator, listname-digest, listname-digest.config,
# listname.closed, listname.private, listname.auto, listname.passwd,
# listname.strip, and listname.hidden.
sub getDomoConfig {
   my ($listName, $listOwnerMap) = @_;
   my $listPath = "$DOMO_LIST_DIR/$listName";
   # All of these values come from <listname>.config unless a comment
   # says otherwise.
   my %config = (
      'admin_passwd'           => '',  # from the list config or passwd files
      'administrivia'          => '',
      'advertise'              => '',
      'aliases_owner'          => $listOwnerMap->{$listName},
      'announcements'          => 'yes',
      'approve_passwd'         => '',
      'description'            => "$listName Mailing List",
      'digest_subscribers'     => '',
      'get_access'             => '',
      'index_access'           => '',
      'info_access'            => '',
      'intro_access'           => '',
      'info'                   => '',  # from the <listname>.info file
      'intro'                  => '',  # from the <listname>.intro file
      'list_name'              => $listName,
      'message_footer'         => '',
      'message_footer_digest'  => '',  # from the <listname>-digest.config file
      'message_fronter'        => '',
      'message_fronter_digest' => '',  # from the <listname>-digest.config file
      'message_headers'        => '',
      'moderate'               => 'no',
      'moderator'              => '',
      'moderators'             => '',  # from the emails in <listname>.moderator
      'noadvertise'            => '',
      'post_access'            => '',
      'reply_to'               => '',
      'resend_host'            => '',
      'restrict_post'          => '',
      'restrict_post_emails'   => '',  # from the emails in restrict_post files
      'strip'                  => '',
      'subject_prefix'         => '',
      'subscribe_policy'       => '',
      'subscribers'            => '',  # from the emails in the <listname> file
      'taboo_body'             => '',
      'taboo_headers'          => '',
      'unsubscribe_policy'     => '',
      'welcome'                => 'yes',
      'which_access'           => '',
      'who_access'             => '',
   );

   # Parse <listname>.config for list configuration options
   my $configPath = "$listPath.config";
   open CONFIG, $configPath or $log->die("Can't open $configPath: $!");
   while (my $line = <CONFIG>) {
      # Pull out the config field and its value.
      if ($line =~ /^\s*([^#\s]+)\s*=\s*(.+)\s*$/) {
         my ($option, $value) = ($1, $2);
         $config{$option} = $value;
      # Some config option values span multiple lines.
      } elsif ($line =~ /^\s*([^#\s]+)\s*<<\s*(\b\S+\b)\s*$/) {
         my ($option, $heredocTag) = ($1, $2);
         while (my $line = <CONFIG>) {
            last if $line =~ /^$heredocTag\s*$/;
            $config{$option} .= $line;
         }
      }
   }

   # Parse <listname> for subscribers
   my @subscribers = getFileEmails($listPath);
   $config{'subscribers'} = join ',', @subscribers;

   # Parse <listname>-digest for digest subscribers
   my @digestSubscribers = getFileEmails("$listPath-digest");
   $config{'digest_subscribers'} = join ',', @digestSubscribers;

   # Parse filenames listed in restrict_post for emails with post permissions
   if ($config{'restrict_post'}) {
      my @postPermissions = ();
      for my $restrictPostFilename (split /[\s:]/, $config{'restrict_post'}) {
         # No need to be explicit in Mailman about letting members post to the
         # list because it is the default behavior.
         if ($restrictPostFilename eq $listName) {
            next;
         }

         # If posting is restricted to another list, use Mailman's shortcut
         # reference of '@<listname>' instead of adding those emails
         # individually.
         if ($restrictPostFilename !~ /\-digest$/ and
             exists $listOwnerMap->{$restrictPostFilename}) {
            $log->info("Adding '\@$restrictPostFilename' shortcut list " .
                       "reference to restrict_post_emails...");
            push @postPermissions, "\@$restrictPostFilename";
         } else {
            my @emails = getFileEmails("$DOMO_LIST_DIR/$restrictPostFilename");
            if (@emails) {
               push @postPermissions, @emails;
            }
         }
      }
      $config{'restrict_post_emails'} =
         "'" . (join "','", @postPermissions) . "'";
   } else {
      # If restrict_post is empty, then anyone can post to it. This can be set
      # in Mailman with a regex that matches everything. Mailman requires
      # regexes in the accept_these_nonmembers field to begin with a caret.
      # TODO: test this setting!
      $config{'restrict_post_emails'} = "'^.*'";
   }

   # Parse <listname>.moderator for moderators
   my @moderators = getFileEmails("$listPath.moderator");
   if (defined $config{'moderator'} and $config{'moderator'} and
       not $config{'moderator'} ~~ @moderators) {
      push @moderators, $config{'moderator'};
   }
   if (@moderators) {
      $config{'moderators'} = "'" . (join "', '", @moderators) . "'";
   }

   $config{'info'} = getFileTxt("$listPath.info", ('skip_dates' => 1));
   $config{'intro'} = getFileTxt("$listPath.intro", ('skip_dates' => 1));

   #
   # Overwrite some config values if legacy files/settings exist.
   #
   if (-e "$listPath.private") {
      for my $option (qw/get_access index_access which_access who_access/) {
         $config{$option} = "closed";
      }
   }

   if (-e "$listPath.closed") {
      $config{'subscribe_policy'} = "closed";
      $config{'unsubscribe_policy'} = "closed";
      if (-e "$listPath.auto") {
         $log->warning("$listName.auto and $listName.closed exist. Setting " .
                       "the list as closed.");
      }
   } elsif (-e "$listPath.auto") {
      $config{'subscribe_policy'} = "auto";
      $config{'unsubscribe_policy'} = "auto";
   }

   $config{'strip'} = 1 if -e "$listPath.strip";
   $config{'noadvertise'} = '/.*/' if -e "$listPath.hidden";

   # Password precedence:
   #  (1) $DOMO_LIST_DIR/$config{(admin|approve)_passwd} file
   #  (2) The (admin|approve)_passwd value itself in <listname>.config
   #  (3) <listname>.passwd file
   for my $passwdOption (qw/admin_passwd approve_passwd/) {
      my $passwdFile = "$DOMO_LIST_DIR/$config{$passwdOption}";
      if (-e $passwdFile) {
         $config{$passwdOption} = getFileTxt($passwdFile,
                                             ('first_line_only' => 1));
      } elsif (not $config{$passwdOption} and -e "$listPath.passwd") {
         $config{$passwdOption} = getFileTxt("$listPath.passwd",
                                             ('first_line_only' => 1));
      }
   }

   # admin_password is required to non-interactively run Mailman's bin/newlist.
   if (not $config{'admin_passwd'}) {
      $log->warning("No admin_passwd or $listName.passwd file. Skipping...");
      $domoStats->{'general_stats'}->{'Lists without admin_passwd'} += 1;
      return;
   }

   # Munge Majordomo text that references Majordomo-specific commands, etc
   for my $field (qw/info intro message_footer message_fronter
                     message_headers/) {
      # Convert references from the majordomo@ admin email to the Mailman one.
      $config{$field} =~ s/majordomo\@/$listName-request\@/mgi;
      # Change owner-<listname>@... to <listname>-owner@...
      $config{$field} =~ s/owner-$listName\@/$listName-owner\@/mgi;
      # Remove the mailing list name from the Majordomo commands.
      $config{$field} =~
         s/(subscribe|unsubscribe)\s*$listName(\-digest)?/$1/mgi;
      # Remove the "end" on a single line listed after all Majordomo commands.
      $config{$field} =~ s/(\s+)end(\s+|\s*\n|$)/$1   $2/mgi;
   }
   $log->debug("Majordomo config for list $listName:\n" . dump(\%config) .
               "\n");

   return %config;
}

# Create and return a hash of Mailman configuration options and values.
# The hash is initialized to the most common default values and then modified
# based on the Majordomo list configuration.
#
# **** The following Majordomo configuration options are not imported. ****
# archive_dir - dead option in Majordomo, so safe to ignore.
# comments - notes section for list admin; safe to ignore.
# date_info - puts a datetime header at top of info file; very safe to ignore.
# date_intro - puts a datetime header at top of intro file; very safe to ignore.
# debug - only useful for the Majordomo admin; very safe to ignore.
# digest_* - digest options don't match up well in Mailman; semi-safe to ignore.
# get_access - who can retrieve files from archives.  Safe to ignore because the
#              "index_access" is consulted to determine archive access.
# message_headers - email headers. Not in Mailman, and probably important for
#                   some lists.
# mungedomain - not recommended to be set in Majordomo, so safe to ignore.
# precedence - mailman handles precedence internally, so safe to ignore.
# purge_received - majordomo recommends not setting this, so safe to ignore.
# resend_host - system setting that never changes, so safe to ignore.
# sender - system setting that never changes, so safe to ignore.
# strip - whether to strip everything but the email address. Not in Mailman.
# taboo_body - message body filtering; roughly used below.  Not in Mailman.
# which_access - get the lists an email is subscribed to.  Not in Mailman.
#
# Additionally, the message_fronter and message_footer of the digest version of
# the list is not imported because of the difficulty in parsing and translating
# that text and because Mailman by default includes its own message header.
sub getMailmanConfig {
   my (%domoConfig) = @_;
   my $listName = $domoConfig{'list_name'};

   # Set default Mailman list configuration values
   my %mmConfig = (
      'accept_these_nonmembers'   => "[$domoConfig{'restrict_post_emails'}]",
      'admin_immed_notify'        => 1,
      'admin_notify_mchanges'     =>
         $domoConfig{'announcements'} =~ /y/i ? 1 : 0,
      'administrivia'             => 'True',
      'advertised'                => 1,
      'anonymous_list'            => 'False',
      'author_is_list'            => 'False',
      # NOTE: some may wish to map some Majordomo setting, such as index_access
      # to Mailman's archive. As is, all archiving is turned off for imported
      # lists.
      'archive'                   => 'False',  # This doesn't change below
      'archive_private'           =>
         $domoConfig{'index_access'} =~ /open/ ? 0 : 1,
      'bounce_processing'         => 1,  # This doesn't change below
      'default_member_moderation' => 0,
      'description'               => "'''$domoConfig{'description'}'''",
      'digest_header'             =>
         "'''$domoConfig{'message_fronter_digest'}'''",
      'digest_footer'             =>
         "'''$domoConfig{'message_footer_digest'}'''",
      'digest_is_default'         => 'False',
      'digestable'                => 'True',
      'filter_content'            => 'False',
      'forward_auto_discards'     => 1,  # This doesn't change below
      'generic_nonmember_action'  => 3,  # 3: discard
      'goodbye_msg'               => '',
      'header_filter_rules'       => '[]',
      'host_name'                 => "'$NEW_HOSTNAME'",
      'info'                      => '',
      'max_message_size'          => 100,  # KB (40 is Mailman's default)
      'moderator'                 => "[$domoConfig{'moderators'}]",
      'msg_header'                => '',
      'msg_footer'                => '',
      'nondigestable'             => 1,
      'obscure_addresses'         => 1,  # This doesn't change below
      'owner'                     => "[$domoConfig{'aliases_owner'}]",
      'personalize'               => 0,
      'preferred_language'        => "'$LANGUAGE'",
      'private_roster'            => 2,  # 0: open; 1: members; 2: admin
      'real_name'                 => "'$listName'",
      'reply_goes_to_list'        => 0,  # 0: poster, 1: list, 2: address
      'reply_to_address'          => '',
      'respond_to_post_requests'  => 1,
      'send_reminders'            => 'False',
      'send_welcome_msg'          => $domoConfig{'welcome'} =~ /y/i ? 1 : 0,
      'subject_prefix'            => "'$domoConfig{'subject_prefix'}'",
      'subscribe_policy'          => 3,  # 1: confirm; 3: confirm and approval
      'unsubscribe_policy'        => 0,  # 0: can unsubscribe; 1: can not
      'welcome_msg'               => '',
   );

   # Majordomo's "who_access" => Mailman's "private_roster"
   if ($domoConfig{'who_access'} =~ /list/i) {
      $mmConfig{'private_roster'} = 1;
   } elsif ($domoConfig{'who_access'} =~ /open/i) {
      $mmConfig{'private_roster'} = 0;
   }

   # Majordomo's "administrivia" => Mailman's "administrivia"
   if ($domoConfig{'administrivia'} =~ /no/i) {
      $mmConfig{'administrivia'} = 'False';
   }

   # Majordomo's "resend_host" => Mailman's "host_name"
   if ($domoConfig{'resend_host'} and not $NEW_HOSTNAME) {
      $mmConfig{'host_name'} = "'$domoConfig{'resend_host'}'";
   }

   # Majordomo's "message_fronter" => Mailman's "msg_header"
   # Majordomo's "message_footer" => Mailman's "msg_footer"
   for my $fieldsArray (['message_fronter', 'msg_header'],
                        ['message_footer', 'msg_footer']) {
      my ($domoOption, $mmOption) = @$fieldsArray;
      if ($domoConfig{$domoOption}) {
         $mmConfig{$mmOption} = "'''$domoConfig{$domoOption}'''";
      }
   }

   # Majordomo's "maxlength" (# chars) => Mailman's "max_message_size" (KB)
   if ($domoConfig{'maxlength'}) {
      my $charsInOneKB = 500;  # 1KB = 500 characters
      $mmConfig{'max_message_size'} = $domoConfig{'maxlength'} / $charsInOneKB;
      if ($mmConfig{'max_message_size'} > $MAX_MSG_SIZE) {
         $mmConfig{'max_message_size'} = $MAX_MSG_SIZE;
      }
   }

   # Majordomo's "taboo_headers" => Mailman's "header_filter_rules"
   if ($domoConfig{'taboo_headers'}) {
      my @rules = split /\n/, $domoConfig{'taboo_headers'};
      $mmConfig{'header_filter_rules'} = "[('" . (join '\r\n', @rules) .
                                         "', 3, False)]";
   }

   # Majordomo's "taboo_body" and "taboo_headers" => Mailman's "filter_content"
   #
   # NOTE: This is a very rough mapping.  What we're doing here is turning on
   # default content filtering in Mailman if there was *any* header or body
   # filtering in Majordomo.  The regexes in the taboo_* fields in Majordomo are
   # too varied for pattern-parsing.  This blunt method is a paranoid,
   # conservative approach.
   if ($domoConfig{'taboo_headers'} or $domoConfig{'taboo_body'}) {
      $mmConfig{'filter_content'} = "True";
   }

   # Majordomo's "subscribe_policy" => Mailman's "subscribe_policy"
   if ($domoConfig{'subscribe_policy'} =~ /open(\+confirm)?/i) {
      $mmConfig{'subscribe_policy'} = 1;
   }

   # Majordomo's "unsubscribe_policy" => Mailman's "unsubscribe_policy"
   if ($domoConfig{'unsubscribe_policy'} =~ /closed/i) {
      $mmConfig{'unsubscribe_policy'} = 1;
   }

   # Majordomo's "moderate" => Mailman's "default_member_moderation"
   if ($domoConfig{'moderate'} =~ /yes/i) {
      $mmConfig{'default_member_moderation'} = 1;
   }

   # Majordomo's "advertise", "noadvertise", "intro_access", "info_access",
   # and "subscribe_policy" => Mailman's "advertised"
   #
   # NOTE: '(no)?advertise' in Majordomo contain regexes, which would be
   # difficult to parse accurately, so just be extra safe here by considering
   # the existence of anything in '(no)?advertise' to mean that the list should
   # be hidden. Also hide the list if intro_access, info_access, or
   # subscribe_policy are at all locked down. This is an appropriate setting
   # for organizations with sensitive data policies (e.g. SOX, FERPA, etc), but
   # not ideal for open organizations with their Mailman instance hidden from
   # the Internet.
   if ($domoConfig{'advertise'} or
       $domoConfig{'noadvertise'} or
       $domoConfig{'intro_access'} =~ /(list|closed)/i or
       $domoConfig{'info_access'} =~ /(list|closed)/i or
       $domoConfig{'subscribe_policy'} =~ /close/i) {
      $mmConfig{'advertised'} = 0;
   }

   # Majordomo's "reply_to" => Mailman's "reply_goes_to_list" and
   # "reply_to_address"
   if ($domoConfig{'reply_to'} =~ /\$sender/i) {
      $mmConfig{'reply_goes_to_list'} = 0;
   } elsif ($domoConfig{'reply_to'} =~ /(\$list|$listName@)/i) {
       $domoConfig{'reply_to'} =~ /\$list/i or
      $mmConfig{'reply_goes_to_list'} = 1;
   } elsif ($domoConfig{'reply_to'} =~ /\s*[^@]+@[^@]+\s*/) {
      $mmConfig{'reply_goes_to_list'} = 2;
      $mmConfig{'reply_to_address'} = "'" . strip($domoConfig{'reply_to'}) .
                                      "'";
   }

   # Majordomo's "subject_prefix" => Mailman's "subject_prefix"
   if ($mmConfig{'subject_prefix'}) {
      $mmConfig{'subject_prefix'} =~ s/\$list/$listName/i;
   }

   # Majordomo's "welcome to the list" message for new subscribers exists in
   # <listname>.intro or <listname>.info.  <listname>.intro takes precedence
   # so this is checked first.  If it doesn't exist, <listname>.info is used,
   # if it exists.
   if ($domoConfig{'intro'}) {
      $mmConfig{'welcome_msg'} = "'''$domoConfig{'intro'}'''";
   } elsif ($domoConfig{'info'}) {
      $mmConfig{'welcome_msg'} = "'''$domoConfig{'info'}'''";
   }

   if ($domoConfig{'message_headers'}) {
      $log->warning("List $listName has message_headers set in Majordomo, " .
                    "but they can't be imported.");
   }

   $log->debug("Mailman config for list $listName: " . dump(\%mmConfig) .
               "\n");

   return %mmConfig;
}

# Call $MM_NEWLIST to create a new Mailman list.
sub createMailmanList {
   my ($listName, $ownerEmail, $listPassword) = @_;
   # Any additional owners will be added when configureMailmanList() is called.
   $ownerEmail = (split /,/, $ownerEmail)[0];
   $ownerEmail =~ s/['"\[\]]//g;
   my $cmd = "$MM_NEWLIST -l $LANGUAGE -q $listName $ownerEmail " .
             "'$listPassword' >> $LOG_FILE 2>&1";
   $log->debug("Calling $cmd...");
   system($cmd) == 0 or $log->die("Command failed: $!");
}

# Create a temporary file that contains a list's configuration values that have
# been translated from Majordomo into Mailman's list template format.
sub createMailmanConfigFile {
   my ($domoApprovePasswd, %mmConfig) = @_;
   my $configFh = File::Temp->new(SUFFIX => ".mm.cfg", UNLINK => 0);
   print $configFh "# coding: utf-8\n";
   for my $cfgField (sort keys %mmConfig) {
      if ($mmConfig{$cfgField}) {
         print $configFh "$cfgField = $mmConfig{$cfgField}\n";
      }
   }

   # The moderator password must be set with Python instead of a config setting.
   if ($domoApprovePasswd) {
      print $configFh <<END;

from Mailman.Utils import sha_new
mlist.mod_password = sha_new('$domoApprovePasswd').hexdigest()
END
   }
   return $configFh->filename;
}

# Call $MM_CONFIGLIST to apply the just-created Mailman configuration options
# file to a Mailman list.
sub configureMailmanList {
   my ($listName, $configFilePath) = @_;
   # Redirect STDOUT/STDERR to the log file to hide the "attribute 'sha_new'
   # ignored" message. This message occurs because Python code to set the
   # moderator password exists at the bottom of the Mailman config file that
   # this script created.
   my $cmd = "$MM_CONFIGLIST -i $configFilePath $listName >> $LOG_FILE 2>&1";
   $log->debug("Calling $cmd...");
   system($cmd) == 0 or $log->die("Command failed: $!");
}

# Create a temporary file with a single email address per line to be used later
# on to subscribe these emails to a Mailman list.
sub createMailmanMembersList {
   my $membersString = shift;
   if ($membersString) {
      my $membersFh = File::Temp->new(SUFFIX => ".mm.members", UNLINK => 0);
      for my $memberEmail (split ',', $membersString) {
         print $membersFh strip($memberEmail) . "\n";
      }
      return $membersFh->filename;
   }
   return '';
}

# Call $MM_ADDMEMBERS to subscribe email addresses to a Mailman list.
sub addMembersToMailmanList {
   my ($listName, $membersFilePath, $digestMembersFilePath) = @_;
   my $cmd = "$MM_ADDMEMBERS -w n -a n";
   $cmd .= " -r $membersFilePath" if $membersFilePath;
   $cmd .= " -d $digestMembersFilePath" if $digestMembersFilePath;
   $cmd .= " $listName >> $LOG_FILE";
   $log->debug("Calling $cmd...");
   system($cmd) == 0 or $log->die("Command failed: $!");
}

# Take the passed in list's Majordomo config and append many of its values to
# the global $domoStats hash ref.
sub appendDomoStats {
   my (%domoConfig) = @_;
   my $listName = $domoConfig{'list_name'};
   # Some fields are uninteresting or part of other fields (e.g. 'moderator'
   # is in 'moderators').
   my @skipFields = qw/archive_dir comments date_info date_intro date_post
                       list_name message_footer_digest message_fronter_digest
                       moderator restrict_post_emails/;
   # Some fields are highly variable, so collapse them into 'has value' and
   # 'no value' values.
   my @yesNoFields = qw/admin_passwd advertise aliases_owner approve_passwd
                        bounce_text description info intro message_footer
                        message_fronter message_headers noadvertise
                        taboo_body taboo_headers/;

   # Run through every Majordomo configuration field and count values.
   for my $field (keys %domoConfig) {
      # Standardize/tidy up the fields and their values.
      $field = lc($field);
      my $value = lc(strip($domoConfig{$field}));

      # Skip unimportant fields
      next if $field ~~ @skipFields;

      # Handle all of the highly variable fields by collapsing their values into
      # one of two choices: does the field have a value or not?
      if ($field ~~ @yesNoFields) {
         $value = $value ? 'has value' : 'no value';
         $domoStats->{$field}->{$value} += 1;
         next;
      }

      # Some fields are moderately variable, but they are important to know
      # about. Handle those fields individually to provide more granular data.
      if ($field eq 'restrict_post') {
         for my $restriction (split /[\s:]/, $domoConfig{'restrict_post'}) {
            if (strip($restriction) eq $listName) {
               $domoStats->{$field}->{'list'} += 1;
            } elsif (strip($restriction) eq "$listName-digest") {
               $domoStats->{$field}->{'list-digest'} += 1;
            } elsif (strip($restriction) eq "$listName.post") {
               $domoStats->{$field}->{'list.post'} += 1;
            } else {
               $domoStats->{$field}->{'other'} += 1;
            }
         }
         next;
      } elsif ($field eq 'sender') {
         if (not $value) {
            $value = 'no value';
         } elsif ($value =~ /^owner-$listName/i) {
            $value = 'owner-list';
         } elsif ($value =~ /^owner-/ and $value !~ /@/) {
            $value = 'owner of another list';
         } else {
            $value = 'other';
         }
      } elsif ($field eq 'subject_prefix' or $field eq 'digest_name') {
         if (not $value) {
            $value = 'no value';
         } elsif ($value =~ /^\s*(\$list|\W*$listName\W*)/i) {
            $value = 'list';
         } else {
            $value = 'other';
         }
      } elsif ($field eq 'reply_to') {
         if (not $value) {
            $value = 'no value';
         } elsif ($value =~ /\$(list|sender)/i) {
            $value = $1;
         } elsif ($value =~ /^$listName(-list)?/) {
            $value = 'list';
         } else {
            $value = 'other';
         }
      } elsif ($field =~ /^(subscribers|digest_subscribers|moderators)/) {
         my $count = () = split /,/, $value, -1;
         if (not $count) {
            $domoStats->{$field}->{'0'} += 1;
            next;
         }
         $domoStats->{$field}->{'2000+'} += 1 if $count >= 2000;
         $domoStats->{$field}->{'1000-2000'} += 1 if $count >= 1000 and $count < 2000;
         $domoStats->{$field}->{'500-999'} += 1 if $count >= 500 and $count < 1000;
         $domoStats->{$field}->{'101-500'} += 1 if $count <= 500 and
                                                   $count > 100;
         $domoStats->{$field}->{'26-100'} += 1 if $count <= 100 and $count > 25;
         $domoStats->{$field}->{'6-25'} += 1 if $count <= 25 and $count > 5;
         $domoStats->{$field}->{'1-5'} += 1 if $count < 5;
         $domoStats->{'general_stats'}->{'Total subscribers'} += $count;
         next;
      } elsif ($field eq 'maxlength') {
         $value = 0 if not $value;
         $domoStats->{$field}->{'1,000,000+'} += 1 if $value > 1000000;
         $domoStats->{$field}->{'100,000-999,999'} += 1 if $value >= 100000 and
                                                           $value < 1000000;
         $domoStats->{$field}->{'50,000-99,999'} += 1 if $value >= 50000 and
                                                         $value < 100000;
         $domoStats->{$field}->{'0-49,999'} += 1 if $value < 50000;
         next;
      }

      $value = 'no value' if not $value;
      $domoStats->{$field}->{$value} += 1;

   }
   $domoStats->{'general_stats'}->{'Total lists'} += 1;
}

sub printStats {
   if (not %$domoStats) {
      print "No stats were generated.\n";
      return;
   }

   print <<END;
+-----------------+
| Majordomo Stats |
+-----------------+
Total Lists: $domoStats->{'general_stats'}->{'Total lists'}

Config Options
--------------
END
   for my $option (sort keys %$domoStats) {
      next if $option eq 'general_stats';
      print " * $option: ";
      for my $value (sort { $domoStats->{$option}->{$b} <=>
                            $domoStats->{$option}->{$a} }
                     keys %{$domoStats->{$option}}) {
         print "$value ($domoStats->{$option}->{$value}), ";
      }
      print "\n";
   }

   if ($domoStats and 
       exists $domoStats->{'general_stats'} and
       defined $domoStats->{'general_stats'} and
       keys %{$domoStats->{'general_stats'}}) {
      print "\nImportant Information" .
            "\n---------------------\n";
      for my $field (sort keys %{$domoStats->{'general_stats'}}) {
         next if $field eq 'Total lists';
         print " * $field: $domoStats->{'general_stats'}->{$field}\n";
      }
      print "\n";
   }
}

#
# Utility functions
#

# Print the help menu to screen and exit.
sub help {
   print <<EOF

   Usage: $SCRIPT_NAME [--loglevel=LEVEL] [--stats]
          [--list=NAME] [--all] [--subscribers]

   Examples:
      # Print stats about your Majordomo lists
      ./$SCRIPT_NAME --stats

      # Verbosely import the 'law-school' mailing list and its subscribers
      ./$SCRIPT_NAME --loglevel=debug --list=law-school --subscribers

      # Import all Majordomo lists and their subscribers
      ./$SCRIPT_NAME --all --subscribers

   Options:
      --all          Import all Majordomo lists
      --list=NAME    Import a single list
      --subscribers  Import subscribers in addition to creating the list
      --stats        Print some stats about your Majordomo lists
      --email-notify Email Majordomo list owners about the upcoming migration
      --email-test   Email Majordomo list owners with link to test server
      --loglevel     Set STDOUT log level.
                     Possible values: debug, info, notice, warning, error
                     Note: All log entries still get written to the log file.
      --help         Print this screen

EOF
;
   exit;
}

# Slurp a file into a variable, optionally skipping the Majordomo datetime
# header or only grabbing the first line.
sub getFileTxt {
   my $filePath = shift;
   my %args = (
      'skip_dates'      => 0,
      'first_line_only' => 0,
      @_
   );
   my $fileTxt = '';
   if (-e $filePath) {
      open FILE, $filePath or $log->die("Can't open $filePath: $!");
      while (my $line = <FILE>) {
         next if $args{'skip_dates'} and $line =~ /^\s*\[Last updated on:/;
         $fileTxt .= $line;
         if ($args{'first_line_only'}) {
            $fileTxt = strip($fileTxt);
            last;
         }
      }
      close FILE or $log->die("Can't close $filePath: $!");
   }
   return $fileTxt;
}

# Given a text file, extract one email per line.  Return these emails in a hash.
sub getFileEmails {
   my $filePath = shift;
   my %emails = ();
   if (-e $filePath) {
      open FILE, $filePath or $log->die("Can't open $filePath: $!");
      while (my $line = <FILE>) {
         if ($line =~ /^#/) {
            next;
         }
         if ($line =~ /\b([A-Za-z0-9._-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,4})\b/) {
            $emails{lc($1)} = 1;
         }
      }
      close FILE or $log->die("Can't close $filePath: $!");
   }
   return keys %emails;
}

# Undo any side-effects of this script (e.g. temporary files, permissions, etc).
sub cleanUp {
   # Delete temporary config files.
   $log->debug("Deleting $TMP_DIR/*.mm.* files...");
   opendir DIR, $TMP_DIR or $log->die("Can't open dir $TMP_DIR: $!");
   my @tmpFiles = grep { /\.mm\.[a-z]+$/i } readdir DIR;
   closedir DIR or $log->die("Can't close dir $TMP_DIR: $!");
   for my $tmpFile (@tmpFiles) {
      unlink "$TMP_DIR/$tmpFile";
   }

   # Fix permissions of newly created Mailman list files.
   my $cmd = "$MM_CHECK_PERMS -f >> $LOG_FILE 2>&1";
   $log->debug("Calling $cmd...");
   system($cmd) == 0 or $log->die("Command failed: $!");
   $log->debug("Calling $cmd again for good measure...");
   system($cmd) == 0 or $log->die("Command failed: $!");
}

# Strip whitespace from the beginning and end of a string.
sub strip {
   my $string = shift || '';
   my $args = shift;
   $string =~ s/(^\s*|\s*$)//g;
   if (exists $args->{'full'}) {
      $string =~ s/(^['"]+|['"]+$)//g;
   }
   return $string;
}