Annotation of CVSROOT/cvsspam.rb, revision 1.1

1.1     ! uid12904    1: #!/usr/bin/ruby -w
        !             2: 
        !             3: # Part of CVSspam
        !             4: #   http://www.badgers-in-foil.co.uk/projects/cvsspam/
        !             5: # Copyright (c) David Holroyd
        !             6: 
        !             7: # collect_diffs.rb expects to find this script in the same directory as it
        !             8: #
        !             9: 
        !            10: # TODO: exemplify syntax for 'cvs admin -m' when log message is missing
        !            11: # TODO: make max-line limit on diff output configurable
        !            12: # TODO: put more exact max size limit on whole email
        !            13: # TODO: support non-html mail too (text/plain, multipart/alternative)
        !            14: 
        !            15: # If you want another 'todo keyword' (TODO & FIXME are highlighted by default)
        !            16: # you could add
        !            17: #   $task_keywords << "KEYWORD" << "MAYBEANOTHERWORD"
        !            18: # to your cvssppam.conf
        !            19: 
        !            20: 
        !            21: $version = "0.2.12"
        !            22: 
        !            23: 
        !            24: $maxSubjectLength = 200
        !            25: $maxLinesPerDiff = 1000
        !            26: $maxDiffLineLength = 1000      # may be set to nil for no limit
        !            27: $charset = nil                 # nil implies 'don't specify a charset'
        !            28: $mailSubject = ''
        !            29: 
        !            30: def blah(text)
        !            31:   $stderr.puts("cvsspam.rb: #{text}") if $debug
        !            32: end
        !            33: 
        !            34: def min(a, b)
        !            35:   a<b ? a : b
        !            36: end
        !            37: 
        !            38: # NB must ensure the time is UTC
        !            39: # (the Ruby Time object's strftime() doesn't supply a numeric timezone)
        !            40: DATE_HEADER_FORMAT = "%a, %d %b %Y %H:%M:%S +0000"
        !            41: 
        !            42: # Perform (possibly) multiple global substitutions on a string.
        !            43: # the regexps given as keys must not use capturing subexpressions '(...)'
        !            44: class MultiSub
        !            45:   # hash has regular expression fragments (as strings) as keys, mapped to
        !            46:   # Procs that will generate replacement text, given the matched value.
        !            47:   def initialize(hash)
        !            48:     @mash = Array.new
        !            49:     expr = nil
        !            50:     hash.each do |key,val|
        !            51:       if expr == nil ; expr="(" else expr<<"|(" end
        !            52:       expr << key << ")"
        !            53:       @mash << val
        !            54:     end
        !            55:     @re = Regexp.new(expr)
        !            56:   end
        !            57: 
        !            58:   # perform a global multi-sub on the given text, modifiying the passed string
        !            59:   # 'in place'
        !            60:   def gsub!(text)
        !            61:     text.gsub!(@re) { |match|
        !            62:       idx = -1
        !            63:       $~.to_a.each { |subexp|
        !            64:         break unless idx==-1 || subexp==nil
        !            65:         idx += 1
        !            66:       }
        !            67:       idx==-1 ? match : @mash[idx].call(match)
        !            68:     }
        !            69:   end
        !            70: end
        !            71: 
        !            72: # returns the character-code of the given character
        !            73: def chr(txt)
        !            74:   txt[0]
        !            75: end
        !            76: 
        !            77: # Limited support for encoding non-US_ASCII characters in mail headers
        !            78: class HeaderEncoder
        !            79:   def initialize
        !            80:     @right_margin = 78
        !            81:     @encoding = 'q' # quoted-printable, base64 not supported
        !            82:     @charset = nil # TODO: some better default?
        !            83:   end
        !            84: 
        !            85:   # character set to be used if any encoding is required.  defaults to nil,
        !            86:   # which will cause an exception if encoding is attempted without another
        !            87:   # value being specified
        !            88:   attr_accessor :charset
        !            89: 
        !            90:   # write an encoded version of the header name/value to the given io
        !            91:   def encode_header(io, name, value)
        !            92:     name = name + ": "
        !            93:     if requires_rfc2047?(value)
        !            94:       rfc2047_encode_quoted(io, name, value)
        !            95:     else
        !            96:       wrap_basic_header(io, name, value)
        !            97:     end
        !            98:   end
        !            99: 
        !           100: 
        !           101:  private
        !           102:   # word wrap long headers, putting a space at the begining of wraped lines
        !           103:   # (i.e. SMTP header continuations)
        !           104:   def wrap_basic_header(io, start, rest)
        !           105:     rest.scan(/\s*\S+/) do |match|
        !           106:       if start.length>0 && start.length+match.length>@right_margin
        !           107:         io.puts(start)
        !           108:         start = " "
        !           109:         match.sub!(/^\s+/, "") # strip existing leading-whitespace
        !           110:       end
        !           111:       start << match
        !           112:     end
        !           113:     io.puts(start)
        !           114:   end
        !           115: 
        !           116:   UNDERSCORE = chr("_")
        !           117:   SPACE = chr(" ")
        !           118:   TAB = chr("\t")
        !           119: 
        !           120:   # encode a header value according to the RFC-2047 quoted-printable spec,
        !           121:   # allowing non-ASCII characters to appear in header values, and wrapping
        !           122:   # long values with header continuation lines as needed
        !           123:   def rfc2047_encode_quoted(io, start, rest)
        !           124:     raise "no charset" if @charset.nil?
        !           125:     code_begin = marker_start_quoted
        !           126:     start << code_begin
        !           127:     each_char_encoded(rest) do |code|
        !           128:       if start.length+code.length+2 > @right_margin
        !           129:         io.puts(start + marker_end_quoted)
        !           130:         start = " " + code_begin
        !           131:       end
        !           132:       start << code
        !           133:     end
        !           134:     io.puts(start + marker_end_quoted)
        !           135:   end
        !           136: 
        !           137:   # return a string representing the given character-code in quoted-printable
        !           138:   # format
        !           139:   def quoted_encode_char(b)
        !           140:     if b>126 || b==UNDERSCORE || b==TAB
        !           141:       sprintf("=%02x", b)
        !           142:     elsif b == SPACE
        !           143:       "_"
        !           144:     else
        !           145:       b.chr
        !           146:     end
        !           147:   end
        !           148: 
        !           149:   public
        !           150: 
        !           151:   # yields a quoted-printable version of each byte in the given string
        !           152:   def each_char_encoded(text)
        !           153:     text.each_byte do |b|
        !           154:       yield quoted_encode_char(b)
        !           155:     end
        !           156:   end
        !           157: 
        !           158:   # gives the string "?=",which is used to mark the end of a quoted-printable
        !           159:   # characte rsequence
        !           160:   def marker_end_quoted
        !           161:     "?="
        !           162:   end
        !           163: 
        !           164:   # gives a string starting "=?", and including a charset specification, that
        !           165:   # marks the start of a quoted-printable character sequence
        !           166:   def marker_start_quoted
        !           167:     "=?#{@charset}?#{@encoding}?"
        !           168:   end
        !           169: 
        !           170:   # test to see of the given string contains non-ASCII characters
        !           171:   def requires_rfc2047?(word)
        !           172:     (word =~ /[\177-\377]/) != nil
        !           173:   end
        !           174: end
        !           175: 
        !           176: 
        !           177: # Provides access to the datafile previously created by collect_diffs.rb.
        !           178: # Each call to getLines() will return an object that will read lines of the
        !           179: # same 'type' (e.g. lines of commit log comment) from the file, and stop when
        !           180: # lines of a different type (e.g. line giving the next file's name) are
        !           181: # encountered.
        !           182: class LogReader
        !           183:   def initialize(logIO)
        !           184:     @io = logIO
        !           185:     advance
        !           186:   end
        !           187: 
        !           188:   def currentLineCode ; @line[1,1]  end
        !           189: 
        !           190: 
        !           191:   class ConstrainedIO
        !           192:     def initialize(reader)
        !           193:       @reader = reader
        !           194:       @linecode = reader.currentLineCode
        !           195:     end
        !           196: 
        !           197:     def each
        !           198:       return if @reader == nil
        !           199:       while true
        !           200:         yield @reader.currentLine
        !           201:         break unless @reader.advance && currentValid?
        !           202:       end
        !           203:       @reader = nil
        !           204:     end
        !           205: 
        !           206:     def gets
        !           207:       return nil if @reader == nil
        !           208:       line = @reader.currentLine
        !           209:       return nil if line==nil || !currentValid?
        !           210:       @reader.advance
        !           211:       return line
        !           212:     end
        !           213: 
        !           214:     def currentValid?
        !           215:       @linecode == @reader.currentLineCode
        !           216:     end
        !           217:   end
        !           218: 
        !           219:   def getLines
        !           220:     ConstrainedIO.new(self)
        !           221:   end
        !           222: 
        !           223:   def eof ; @line==nil  end
        !           224: 
        !           225:   def advance
        !           226:     @line = @io.gets
        !           227:     return false if @line == nil
        !           228:     unless @line[0,1] == "#"
        !           229:       raise "#{$logfile}:#{@io.lineno} line did not begin with '#': #{@line}"
        !           230:     end
        !           231:     return true
        !           232:   end
        !           233: 
        !           234:   def currentLine
        !           235:     @line==nil ? nil : @line[3, @line.length-4]
        !           236:   end
        !           237: end
        !           238: 
        !           239: 
        !           240: # returns a copy of the fiven string with instances of the HTML special
        !           241: # characters '&', '<' and '>' encoded as their HTML entity equivalents.
        !           242: def htmlEncode(text)
        !           243:   text.gsub(/./) do
        !           244:     case $&
        !           245:       when "&" then "&amp;"
        !           246:       when "<" then "&lt;"
        !           247:       when ">" then "&gt;"
        !           248:       else $&
        !           249:     end
        !           250:   end
        !           251: end
        !           252: 
        !           253: # Encodes characters that would otherwise be special in a URL using the
        !           254: # "%XX" syntax (where XX are hex digits).
        !           255: # actually, allows '/' to appear
        !           256: def urlEncode(text)
        !           257:   text.sub(/[^a-zA-Z0-9\-,.*_\/]/) do
        !           258:     "%#{sprintf('%2X', $&[0])}"
        !           259:   end
        !           260: end
        !           261: 
        !           262: 
        !           263: # Represents a top-level directory under the $CVSROOT (which is properly called
        !           264: # a module -- this class is named incorrectly).  Collects a list of
        !           265: # all #FileEntry objects that are 'in' this repository.  Class methods provide
        !           266: # a list of all repositories (ick!)
        !           267: class Repository
        !           268:   @@repositories = Hash.new
        !           269: 
        !           270:   def initialize(name)
        !           271:     @name = name
        !           272:     @common_prefix = nil
        !           273:     @all_tags = Hash.new
        !           274:   end
        !           275: 
        !           276:   # records that the given branch tag name was used for some file that was
        !           277:   # committed to this repository.  The argument nil is taken to signify the
        !           278:   # MAIN branch, or 'trunk' of the project.
        !           279:   def add_tag(tag_name)
        !           280:     if @all_tags[tag_name]
        !           281:       @all_tags[tag_name] += 1
        !           282:     else
        !           283:       @all_tags[tag_name] = 1
        !           284:     end 
        !           285:   end
        !           286: 
        !           287:   # true, if #add_tag has been passed more than one distinct value
        !           288:   def has_multiple_tags
        !           289:     @all_tags.length > 1
        !           290:   end
        !           291: 
        !           292:   # iterate over the tags that have been recorded against this Repository
        !           293:   def each_tag
        !           294:     @all_tags.each_key do |tag|
        !           295:       yield tag
        !           296:     end
        !           297:   end
        !           298: 
        !           299:   # true if the only tag that has been recorded against this repository was
        !           300:   # the 'trunk', i.e. no branch tags at all
        !           301:   def trunk_only?
        !           302:     @all_tags.length==1 && @all_tags[nil]!=nil
        !           303:   end
        !           304: 
        !           305:   # true if the files committed to this Repository have been of more than one
        !           306:   # branch (not a common situation, I've only seen it in real life when things
        !           307:   # are b0rked in someone's working directory).
        !           308:   def mixed_tags?
        !           309:     @all_tags.length>1
        !           310:   end
        !           311: 
        !           312:   # returns the number of tags seen during the commit to this Repository
        !           313:   def tag_count
        !           314:     @all_tags.length
        !           315:   end
        !           316: 
        !           317:   # calculate the path prefix shared by all files commited to this
        !           318:   # reposotory
        !           319:   def merge_common_prefix(path)
        !           320:     if @common_prefix == nil
        !           321:       @common_prefix = path.dup
        !           322:     else
        !           323:       path = path.dup
        !           324:       until @common_prefix == path
        !           325:         if @common_prefix.size>path.size
        !           326:           if @common_prefix.sub!(/(.*)\/.*$/, '\1').nil?
        !           327:             raise "unable to merge '#{path}' in to '#{@common_prefix}': prefix totally different"
        !           328:           end
        !           329:         else
        !           330:           if path.sub!(/(.*)\/.*$/, '\1').nil?
        !           331:             raise "unable to merge '#{path}' in to '#{@common_prefix}': prefix totally different"
        !           332:           end
        !           333:         end
        !           334:       end
        !           335:     end
        !           336:   end
        !           337: 
        !           338:   attr_reader :name, :common_prefix
        !           339: 
        !           340:   # gets the Repository object for the first component of the given path
        !           341:   def Repository.get(name)
        !           342:     # Leading './' is ignored (for peeps who have done 'cvs checkout .')
        !           343:     # Trailing '/' ensures no match for files in root (we just want dirs)
        !           344:     name =~ /^(?:\.\/)?([^\/]+)\//  
        !           345:     name = $1
        !           346:     name = "/" if name.nil?  # file at top-level?  fake up a name for repo
        !           347:     rep = @@repositories[name]
        !           348:     if rep.nil?
        !           349:       rep =  Repository.new(name)
        !           350:       @@repositories[name] = rep
        !           351:     end
        !           352:     rep
        !           353:   end
        !           354: 
        !           355:   # returns the total number of top-level directories seen during this commit
        !           356:   def Repository.count
        !           357:     @@repositories.size
        !           358:   end
        !           359: 
        !           360:   # iterate over all the Repository objects created for this commit
        !           361:   def Repository.each
        !           362:     @@repositories.each_value do |rep|
        !           363:       yield rep
        !           364:     end
        !           365:   end
        !           366: 
        !           367:   # returns an array of all the repository objects seen during this commit
        !           368:   def Repository.array
        !           369:     @@repositories.values
        !           370:   end
        !           371: 
        !           372:   # get a string representation of the repository to appear in email subjects.
        !           373:   # This will be the repository name, plus (possibly) the name of the branch
        !           374:   # on which the commit occured.  If the commit was to multiple branches, the
        !           375:   # text '..' is used, rather than a branch name
        !           376:   def to_s
        !           377:     if trunk_only?
        !           378:       @name
        !           379:     elsif mixed_tags?
        !           380:       "#{@name}@.."
        !           381:     else
        !           382:       "#{@name}@#{@all_tags.keys[0]}"
        !           383:     end
        !           384:   end
        !           385: end
        !           386: 
        !           387: # Records properties of a file that was changed during this commit
        !           388: class FileEntry
        !           389:   def initialize(path)
        !           390:     @path = path
        !           391:     @lineAdditions = @lineRemovals = 0
        !           392:     @repository = Repository.get(path)
        !           393:     @repository.merge_common_prefix(basedir())
        !           394:     @isEmpty = @isBinary = false
        !           395:     @has_diff = nil
        !           396:   end
        !           397: 
        !           398:   # the full path and filename within the repository
        !           399:   attr_accessor :path
        !           400:   # the type of change committed 'M'=modified, 'A'=added, 'R'=removed
        !           401:   attr_accessor :type
        !           402:   # records number of 'addition' lines in diff output, once counted
        !           403:   attr_accessor :lineAdditions
        !           404:   # records number of 'removal' lines in diff output, once counted
        !           405:   attr_accessor :lineRemovals
        !           406:   # records whether 'cvs diff' reported this as a binary file
        !           407:   attr_accessor :isBinary
        !           408:   # records if diff output (and therefore the added file) was empty
        !           409:   attr_accessor :isEmpty
        !           410:   # file version number before the commit
        !           411:   attr_accessor :fromVer
        !           412:   # file version number after the commit
        !           413:   attr_accessor :toVer
        !           414: 
        !           415:   # works out the filename part of #path
        !           416:   def file
        !           417:     @path =~ /.*\/(.*)/
        !           418:     $1
        !           419:   end
        !           420: 
        !           421:   # set the branch on which this change was committed, and add it to the list
        !           422:   # of branches for which we've seen commits (in the #Repository)
        !           423:   def tag=(name)
        !           424:     @tag = name
        !           425:     @repository.add_tag(name)
        !           426:   end
        !           427: 
        !           428:   # gives the branch on which this change was committed
        !           429:   def tag
        !           430:     @tag
        !           431:   end
        !           432: 
        !           433:   # works out the directory part of #path
        !           434:   def basedir
        !           435:     @path =~ /(.*)\/.*/
        !           436:     $1
        !           437:   end
        !           438: 
        !           439:   # gives the Repository object this file was automatically associated with
        !           440:   # on construction
        !           441:   def repository
        !           442:     @repository
        !           443:   end
        !           444: 
        !           445:   # gets the part of #path that comes after the prefix common to all files
        !           446:   # in the commit to #repository
        !           447:   def name_after_common_prefix
        !           448:     @path.slice(@repository.common_prefix.size+1,@path.size-@repository.common_prefix.size-1)
        !           449:   end
        !           450: 
        !           451:   # was this file removed during the commit?
        !           452:   def removal?
        !           453:     @type == "R"
        !           454:   end
        !           455: 
        !           456:   # was this file added during the commit?
        !           457:   def addition?
        !           458:     @type == "A"
        !           459:   end
        !           460: 
        !           461:   # was this file simply modified during the commit?
        !           462:   def modification?
        !           463:     @type == "M"
        !           464:   end
        !           465: 
        !           466:   # passing true, this object remembers that a diff will appear in the email,
        !           467:   # passing false, this object remembers that no diff will appear in the email.
        !           468:   # Once the value is set, it will not be changed
        !           469:   def has_diff=(diff)
        !           470:     # TODO: this 'if @has_diff.nil?' is counterintuitive; remove!
        !           471:     @has_diff = diff if @has_diff.nil?
        !           472:   end
        !           473: 
        !           474:   # true if this file has had a diff recorded
        !           475:   def has_diff?
        !           476:     @has_diff
        !           477:   end
        !           478: 
        !           479:   # true only if this file's diff (if any) should be included in the email,
        !           480:   # taking into account global diff-inclusion settings.
        !           481:   def wants_diff_in_mail?
        !           482:     !($no_diff ||
        !           483:       removal? && $no_removed_file_diff ||
        !           484:       addition? && $no_added_file_diff)
        !           485:   end
        !           486: end
        !           487: 
        !           488: # Superclass for things that eat lines of input, and turn them into output
        !           489: # for our email.  The 'input' will be provided by #LogReader
        !           490: # Subclasses of LineConsumer will be registered in the global $handlers later
        !           491: # on in this file.
        !           492: class LineConsumer
        !           493:   # passes each line from 'lines' to the consume() method (which must be
        !           494:   # implemented by subclasses).
        !           495:   def handleLines(lines, emailIO)
        !           496:     @emailIO = emailIO
        !           497:     @lineCount = 0
        !           498:     setup
        !           499:     lines.each do |line|
        !           500:       @lineCount += 1
        !           501:       consume(line)
        !           502:     end
        !           503:     teardown
        !           504:   end
        !           505: 
        !           506:   # Template method called by handleLines to do any subclass-specific setup
        !           507:   # required.  Default implementation does nothing
        !           508:   def setup
        !           509:   end
        !           510: 
        !           511:   # Template method called by handleLines to do any subclass-specific cleanup
        !           512:   # required.  Default implementation does nothing
        !           513:   def teardown
        !           514:   end
        !           515: 
        !           516:   # Returns the number of lines handleLines() has seen so far
        !           517:   def lineno
        !           518:     @lineCount
        !           519:   end
        !           520: 
        !           521:   # adds a line to the output
        !           522:   def println(text)
        !           523:     @emailIO.puts(text)
        !           524:   end
        !           525: 
        !           526:   # adds a string to the current output line
        !           527:   def print(text)
        !           528:     @emailIO.print(text)
        !           529:   end
        !           530: end
        !           531: 
        !           532: 
        !           533: # TODO: consolidate these into a nicer framework,
        !           534: mailSub = proc { |match| "<a href=\"mailto:#{match}\">#{match}</a>" }
        !           535: urlSub = proc { |match| "<a href=\"#{match}\">#{match}</a>" }
        !           536: bugzillaSub = proc { |match|
        !           537:   match =~ /([0-9]+)/
        !           538:   "<a href=\"#{$bugzillaURL.sub(/%s/, $1)}\">#{match}</a>"
        !           539: }
        !           540: jiraSub = proc { |match|
        !           541:   "<a href=\"#{$jiraURL.sub(/%s/, match)}\">#{match}</a>"
        !           542: }
        !           543: ticketSub = proc { |match|
        !           544:   match =~ /([0-9]+)/
        !           545:   "<a href=\"#{$ticketURL.sub(/%s/, $1)}\">#{match}</a>"
        !           546: }
        !           547: wikiSub = proc { |match| 
        !           548:   match =~ /\[\[(.*)\]\]/
        !           549:   raw = $1
        !           550:   "<a href=\"#{$wikiURL.sub(/%s/, urlEncode(raw))}\">[[#{raw}]]</a>"
        !           551: }
        !           552: commentSubstitutions = {
        !           553:                '(?:mailto:)?[\w\.\-\+\=]+\@[\w\-]+(?:\.[\w\-]+)+\b' => mailSub,
        !           554:                '\b(?:http|https|ftp):[^ \t\n<>"]+[\w/]' => urlSub
        !           555:                }
        !           556: 
        !           557: # outputs commit log comment text supplied by LogReader as preformatted HTML
        !           558: class CommentHandler < LineConsumer
        !           559:   def initialize
        !           560:     @lastComment = nil
        !           561:   end
        !           562: 
        !           563:   def setup
        !           564:     @haveBlank = false
        !           565:     @comment = ""
        !           566:   end
        !           567: 
        !           568:   def consume(line)
        !           569:     if line =~ /^\s*$/
        !           570:       @haveBlank = true
        !           571:     else
        !           572:       if @haveBlank
        !           573:         @comment += "\n"
        !           574:         @haveBlank = false
        !           575:       end
        !           576: #      $mailSubject = line unless $mailSubject.length > 0
        !           577: #
        !           578:       $mailSubject = "#{Repository.array.join(',')}"
        !           579:       @comment += line += "\n"
        !           580:     end
        !           581:   end
        !           582: 
        !           583:   def teardown
        !           584:     unless @comment == @lastComment
        !           585:       println("<pre class=\"comment\">")
        !           586:       encoded = htmlEncode(@comment)
        !           587:       $commentEncoder.gsub!(encoded)
        !           588:       println(encoded)
        !           589:       println("</pre>")
        !           590:       @lastComment = @comment
        !           591:     end
        !           592:   end
        !           593: end
        !           594: 
        !           595: 
        !           596: # Handle lines from LogReader that represent the name of the branch tag for
        !           597: # the next file in the log.  When files are committed to the trunk, the log
        !           598: # will not contain a line specifying the branch tag name, and getLastTag
        !           599: # will return nil.
        !           600: class TagHandler < LineConsumer
        !           601:   def initialize
        !           602:     @tag = nil
        !           603:   end
        !           604: 
        !           605:   def consume(line)
        !           606:     # TODO: check there is only one line
        !           607:     @tag = line
        !           608:   end
        !           609: 
        !           610:   # returns the last tag name this object recorded, and resets the record, such
        !           611:   # that a subsequent call to this method will return nil
        !           612:   def getLastTag
        !           613:     tmp = @tag
        !           614:     @tag = nil
        !           615:     tmp
        !           616:   end
        !           617: end
        !           618: 
        !           619: # records, from the log file, a line specifying the old and new revision numbers
        !           620: # for the next file to appear in the log.  The values are recorded in the global
        !           621: # variables $fromVer and $toVer
        !           622: class VersionHandler < LineConsumer
        !           623:   def consume(line)
        !           624:     # TODO: check there is only one line
        !           625:     $fromVer,$toVer = line.split(/,/)
        !           626:   end
        !           627: end
        !           628: 
        !           629: # Reads a line giving the path and name of the current file being considered
        !           630: # from our log of all files changed in this commit.  Subclasses make different
        !           631: # records depending on whether this commit adds, removes, or just modifies this
        !           632: # file
        !           633: class FileHandler < LineConsumer
        !           634:   def setTagHandler(handler)
        !           635:     @tagHandler = handler
        !           636:   end
        !           637: 
        !           638:   def consume(line)
        !           639:     $file = FileEntry.new(line)
        !           640:     if $diff_output_limiter.choose_to_limit?
        !           641:       $file.has_diff = false
        !           642:     end
        !           643:     $fileEntries << $file
        !           644:     $file.tag = getTag
        !           645:     handleFile($file)
        !           646:   end
        !           647: 
        !           648:  protected
        !           649:   def getTag
        !           650:     @tagHandler.getLastTag
        !           651:   end
        !           652: end
        !           653: 
        !           654: # A do-nothing superclass for objects that know how to create hyperlinks to
        !           655: # web CVS interfaces (e.g. CVSweb).  Subclasses overide these methods to
        !           656: # wrap HTML link tags arround the text that this classes methods generate.
        !           657: class NoFrontend
        !           658:   # Just returns an HTML-encoded version of the 'path' argument.  Subclasses
        !           659:   # should turn this into a link to a webpage view of this CVS directory
        !           660:   def path(path, tag)
        !           661:     htmlEncode(path)
        !           662:   end
        !           663: 
        !           664:   # Just returns the value of the 'version' argument.  Subclasses should change
        !           665:   # this into a link to the given version of the file.
        !           666:   def version(path, version)
        !           667:     version
        !           668:   end
        !           669: 
        !           670:   # Gerarates a little 'arrow' that superclasses may turn into links that will
        !           671:   # give an alternative 'diff' view of a change.
        !           672:   def diff(file)
        !           673:     '-&gt;'
        !           674:   end
        !           675: end
        !           676: 
        !           677: # Superclass for objects that can link to CVS frontends on the web (ViewCVS,
        !           678: # Chora, etc.).
        !           679: class WebFrontend < NoFrontend
        !           680: 
        !           681:   attr_accessor :repository_name
        !           682: 
        !           683:   def initialize(base_url)
        !           684:     @base_url = base_url
        !           685:     @repository_name = nil
        !           686:   end
        !           687: 
        !           688:   def path(path, tag)
        !           689:     path_for_href = ""
        !           690:     result = ""
        !           691:     path.split("/").each do |component|
        !           692:       unless result == ""
        !           693:         result << "/"
        !           694:         path_for_href << "/"
        !           695:       end
        !           696:       path_for_href << component
        !           697:       # The link is split over two lines so that long paths don't create
        !           698:       # huge HTML source-lines in the resulting email.  This is an attempt to
        !           699:       # avoid having to prroduce a quoted-printable message (so that long lines
        !           700:       # can be dealt with properly),
        !           701:       result << "<a\n"
        !           702:       result << "href=\"#{path_url(path_for_href, tag)}\">#{htmlEncode(component)}</a>"
        !           703:     end
        !           704:     result
        !           705:   end
        !           706: 
        !           707:   def version(path, version)
        !           708:     "<a href=\"#{version_url(path, version)}\">#{version}</a>"
        !           709:   end
        !           710: 
        !           711:   def diff(file)
        !           712:     "<a href=\"#{diff_url(file)}\">#{super(file)}</a>"
        !           713:   end
        !           714: 
        !           715:  protected
        !           716:   def add_repo(url)
        !           717:     if @repository_name
        !           718:       if url =~ /\?/
        !           719:         "#{url}&amp;cvsroot=#{urlEncode(@repository_name)}"
        !           720:       else
        !           721:         "#{url}?cvsroot=#{urlEncode(@repository_name)}"
        !           722:       end
        !           723:     else
        !           724:       url
        !           725:     end
        !           726:   end
        !           727: end
        !           728: 
        !           729: # Link to ViewCVS
        !           730: class ViewCVSFrontend < WebFrontend
        !           731:   def initialize(base_url)
        !           732:     super(base_url)
        !           733:   end
        !           734: 
        !           735:   def path_url(path, tag)
        !           736:     if tag == nil
        !           737:       add_repo(@base_url + urlEncode(path))
        !           738:     else
        !           739:       add_repo("#{@base_url}#{urlEncode(path)}?only_with_tag=#{urlEncode(tag)}")
        !           740:     end
        !           741:   end
        !           742: 
        !           743:   def version_url(path, version)
        !           744:     add_repo("#{@base_url}#{urlEncode(path)}?rev=#{version}&amp;content-type=text/vnd.viewcvs-markup")
        !           745:   end
        !           746: 
        !           747:   def diff_url(file)
        !           748:     add_repo("#{@base_url}#{urlEncode(file.path)}.diff?r1=#{file.fromVer}&amp;r2=#{file.toVer}")
        !           749:   end
        !           750: end
        !           751: 
        !           752: # Link to Chora, from the Horde framework
        !           753: class ChoraFrontend < WebFrontend
        !           754:   def path_url(path, tag)
        !           755:     # TODO: can we pass the tag somehow?
        !           756:     "#{@base_url}/cvs.php/#{urlEncode(path)}"
        !           757:   end
        !           758: 
        !           759:   def version_url(path, version)
        !           760:     "#{@base_url}/co.php/#{urlEncode(path)}?r=#{version}"
        !           761:   end
        !           762: 
        !           763:   def diff_url(file)
        !           764:     "#{@base_url}/diff.php/#{urlEncode(file.path)}?r1=#{file.fromVer}&r2=#{file.toVer}"
        !           765:   end
        !           766: end
        !           767: 
        !           768: # Link to CVSweb
        !           769: class CVSwebFrontend < WebFrontend
        !           770:   def path_url(path, tag)
        !           771:     if tag == nil
        !           772:       add_repo(@base_url + urlEncode(path))
        !           773:     else
        !           774:       add_repo("#{@base_url}#{urlEncode(path)}?only_with_tag=#{urlEncode(tag)}")
        !           775:     end
        !           776:   end
        !           777: 
        !           778:   def version_url(path, version)
        !           779:     add_repo("#{@base_url}#{urlEncode(path)}?rev=#{version}&amp;content-type=text/x-cvsweb-markup")
        !           780:   end
        !           781: 
        !           782:   def diff_url(file)
        !           783:     add_repo("#{@base_url}#{urlEncode(file.path)}.diff?r1=text&amp;tr1=#{file.fromVer}&amp;r2=text&amp;tr2=#{file.toVer}&amp;f=h")
        !           784:   end
        !           785: end
        !           786: 
        !           787: 
        !           788: # in need of refactoring...
        !           789: 
        !           790: # Note when LogReader finds record of a file that was added in this commit
        !           791: class AddedFileHandler < FileHandler
        !           792:   def handleFile(file)
        !           793:     file.type="A"
        !           794:     file.toVer=$toVer
        !           795:   end
        !           796: end
        !           797: 
        !           798: # Note when LogReader finds record of a file that was removed in this commit
        !           799: class RemovedFileHandler < FileHandler
        !           800:   def handleFile(file)
        !           801:     file.type="R"
        !           802:     file.fromVer=$fromVer
        !           803:   end
        !           804: end
        !           805: 
        !           806: # Note when LogReader finds record of a file that was modified in this commit
        !           807: class ModifiedFileHandler < FileHandler
        !           808:   def handleFile(file)
        !           809:     file.type="M"
        !           810:     file.fromVer=$fromVer
        !           811:     file.toVer=$toVer
        !           812:   end
        !           813: end
        !           814: 
        !           815: 
        !           816: # Used by UnifiedDiffHandler to record the number of added and removed lines
        !           817: # appearing in a unidiff.
        !           818: class UnifiedDiffStats
        !           819:   def initialize
        !           820:     @diffLines=3  # the three initial lines in the unidiff
        !           821:   end
        !           822: 
        !           823:   def diffLines
        !           824:     @diffLines
        !           825:   end
        !           826: 
        !           827:   def consume(line)
        !           828:     @diffLines += 1
        !           829:     case line[0,1]
        !           830:       when "+" then $file.lineAdditions += 1
        !           831:       when "-" then $file.lineRemovals += 1
        !           832:     end
        !           833:   end
        !           834: end
        !           835: 
        !           836: # TODO: change-within-line colourisation should really be comparing the
        !           837: #       set of lines just removed with the set of lines just added, but
        !           838: #       it currently considers just a single line
        !           839: 
        !           840: # Used by UnifiedDiffHandler to produce an HTML, 'highlighted' version of
        !           841: # the input unidiff text.
        !           842: class UnifiedDiffColouriser < LineConsumer
        !           843:   def initialize
        !           844:     @currentState = "@"
        !           845:     @currentStyle = "info"
        !           846:     @lineJustDeleted = nil
        !           847:     @lineJustDeletedSuperlong = false
        !           848:     @truncatedLineCount = 0
        !           849:   end
        !           850: 
        !           851:   def output=(io)
        !           852:     @emailIO = io
        !           853:   end
        !           854: 
        !           855:   def consume(line)
        !           856:     initial = line[0,1]
        !           857:     superlong_line = false
        !           858:     if $maxDiffLineLength && line.length > $maxDiffLineLength+1
        !           859:       line = line[0, $maxDiffLineLength+1]
        !           860:       superlong_line = true
        !           861:       @truncatedLineCount += 1
        !           862:     end
        !           863:     if initial != @currentState
        !           864:       prefixLen = 1
        !           865:       suffixLen = 0
        !           866:       if initial=="+" && @currentState=="-" && @lineJustDeleted!=nil
        !           867:         # may be an edit, try to highlight the changes part of the line
        !           868:         a = line[1,line.length-1]
        !           869:         b = @lineJustDeleted[1,@lineJustDeleted.length-1]
        !           870:         prefixLen = commonPrefixLength(a, b)+1
        !           871:         suffixLen = commonPrefixLength(a.reverse, b.reverse)
        !           872:         # prevent prefix/suffux having overlap,
        !           873:         suffixLen = min(suffixLen, min(line.length,@lineJustDeleted.length)-prefixLen)
        !           874:         deleteInfixSize = @lineJustDeleted.length - (prefixLen+suffixLen)
        !           875:         addInfixSize = line.length - (prefixLen+suffixLen)
        !           876:         oversize_change = deleteInfixSize*100/@lineJustDeleted.length>33 || addInfixSize*100/line.length>33
        !           877: 
        !           878:         if prefixLen==1 && suffixLen==0 || deleteInfixSize<=0 || oversize_change
        !           879:           print(htmlEncode(@lineJustDeleted))
        !           880:         else
        !           881:           print(htmlEncode(@lineJustDeleted[0,prefixLen]))
        !           882:           print("<span id=\"removedchars\">")
        !           883:           print(formatChange(@lineJustDeleted[prefixLen,deleteInfixSize]))
        !           884:           print("</span>")
        !           885:           print(htmlEncode(@lineJustDeleted[@lineJustDeleted.length-suffixLen,suffixLen]))
        !           886:         end
        !           887:         if superlong_line
        !           888:           println("<strong class=\"error\">[...]</strong>")
        !           889:         else
        !           890:           println("")
        !           891:         end
        !           892:         @lineJustDeleted = nil
        !           893:       end
        !           894:       if initial=="-"
        !           895:         @lineJustDeleted=line
        !           896:         @lineJustDeletedSuperlong = superlong_line
        !           897:         shift(initial)
        !           898:         # we'll print it next time (fingers crossed)
        !           899:         return
        !           900:       elsif @lineJustDeleted!=nil
        !           901:         print(htmlEncode(@lineJustDeleted))
        !           902:         if @lineJustDeletedSuperlong
        !           903:           println("<strong class=\"error\">[...]</strong>")
        !           904:         else
        !           905:           println("")
        !           906:         end
        !           907:         @lineJustDeleted = nil
        !           908:       end
        !           909:       shift(initial)
        !           910:       if prefixLen==1 && suffixLen==0 || addInfixSize<=0 || oversize_change
        !           911:         encoded = htmlEncode(line)
        !           912:       else
        !           913:         encoded = htmlEncode(line[0,prefixLen]) +
        !           914:         "<span id=\"addedchars\">" +
        !           915:         formatChange(line[prefixLen,addInfixSize]) +
        !           916:         "</span>" +
        !           917:         htmlEncode(line[line.length-suffixLen,suffixLen])
        !           918:       end
        !           919:     else
        !           920:       encoded = htmlEncode(line)
        !           921:     end
        !           922:     if initial=="-"
        !           923:       unless @lineJustDeleted==nil
        !           924:         print(htmlEncode(@lineJustDeleted))
        !           925:         if @lineJustDeletedSuperlong
        !           926:           println("<strong class=\"error\">[...]</strong>")
        !           927:         else
        !           928:           println("")
        !           929:         end
        !           930:         @lineJustDeleted=nil
        !           931:       end
        !           932:     end
        !           933:     if initial=="+"
        !           934:       $task_keywords.each do |task|
        !           935:         if line =~ /\b(#{task}\b.*)/
        !           936:           $task_list << $1
        !           937:           encoded.sub!(/\b#{task}\b/, "<span class=\"task\">#{task}</span>")
        !           938:           encoded = "<a name=\"task#{$task_list.size}\" />" + encoded
        !           939:           break
        !           940:         end
        !           941:       end
        !           942:     end
        !           943:     print(encoded)
        !           944:     if superlong_line
        !           945:       println("<strong class=\"error\">[...]</strong>")
        !           946:     else
        !           947:       println("")
        !           948:     end
        !           949:   end
        !           950: 
        !           951:   def teardown
        !           952:     unless @lineJustDeleted==nil
        !           953:       print(htmlEncode(@lineJustDeleted))
        !           954:       if @lineJustDeletedSuperlong
        !           955:         println("<strong class=\"error\">[...]</strong>")
        !           956:       else
        !           957:         println("")
        !           958:       end
        !           959:       @lineJustDeleted = nil
        !           960:     end
        !           961:     shift(nil)
        !           962:     if @truncatedLineCount>0
        !           963:       println("<strong class=\"error\" title=\"#{@truncatedLineCount} lines truncated at column #{$maxDiffLineLength}\">[Note: Some over-long lines of diff output only partialy shown]</strong>")
        !           964:     end
        !           965:   end
        !           966: 
        !           967:   # start the diff output, using the given lines as the 'preamble' bit
        !           968:   def start_output(*lines)
        !           969:     println("<hr /><a name=\"file#{$fileEntries.size}\" /><div class=\"file\">")
        !           970:     case $file.type
        !           971:       when "A"
        !           972:         print("<span class=\"pathname\" id=\"added\">")
        !           973:         print($frontend.path($file.basedir, $file.tag))
        !           974:         println("</span><br />")
        !           975:         println("<div class=\"fileheader\" id=\"added\"><big><b>#{htmlEncode($file.file)}</b></big> <small id=\"info\">added at #{$frontend.version($file.path,$file.toVer)}</small></div>")
        !           976:       when "R"
        !           977:         print("<span class=\"pathname\" id=\"removed\">")
        !           978:         print($frontend.path($file.basedir, $file.tag))
        !           979:         println("</span><br />")
        !           980:         println("<div class=\"fileheader\" id=\"removed\"><big><b>#{htmlEncode($file.file)}</b></big> <small id=\"info\">removed after #{$frontend.version($file.path,$file.fromVer)}</small></div>")
        !           981:       when "M"
        !           982:         print("<span class=\"pathname\">")
        !           983:         print($frontend.path($file.basedir, $file.tag))
        !           984:         println("</span><br />")
        !           985:         println("<div class=\"fileheader\"><big><b>#{htmlEncode($file.file)}</b></big> <small id=\"info\">#{$frontend.version($file.path,$file.fromVer)} #{$frontend.diff($file)} #{$frontend.version($file.path,$file.toVer)}</small></div>")
        !           986:     end
        !           987:     print("<pre class=\"diff\"><small id=\"info\">")
        !           988:     lines.each do |line|
        !           989:       println(htmlEncode(line))
        !           990:     end
        !           991:   end
        !           992: 
        !           993:  private
        !           994: 
        !           995:   def formatChange(text)
        !           996:     return '<small id="info">^M</small>' if text=="\r"
        !           997:     htmlEncode(text).gsub(/ /, '&nbsp;')
        !           998:   end
        !           999: 
        !          1000:   def shift(nextState)
        !          1001:     unless @currentState == nil
        !          1002:       if @currentStyle == "info"
        !          1003:         print("</small></pre>")
        !          1004:       else
        !          1005:         print("</pre>")
        !          1006:       end
        !          1007:       @currentStyle = case nextState
        !          1008:         when "\\" then "info" # as in '\ No newline at end of file'
        !          1009:         when "@" then "info"
        !          1010:         when " " then "context"
        !          1011:         when "+" then "added"
        !          1012:         when "-" then "removed"
        !          1013:       end
        !          1014:       unless nextState == nil
        !          1015:         if @currentStyle=='info'
        !          1016:           print("<pre class=\"diff\"><small id=\"info\">")
        !          1017:         else
        !          1018:           print("<pre class=\"diff\" id=\"#{@currentStyle}\">")
        !          1019:         end
        !          1020:       end
        !          1021:     end
        !          1022:     @currentState = nextState
        !          1023:   end
        !          1024: 
        !          1025:   def commonPrefixLength(a, b)
        !          1026:     length = 0
        !          1027:     a.each_byte do |char|
        !          1028:       break unless b[length]==char
        !          1029:       length = length + 1
        !          1030:     end
        !          1031:     return length
        !          1032:   end
        !          1033: end
        !          1034: 
        !          1035: 
        !          1036: # Handle lines from LogReader that are the output from 'cvs diff -u' for the
        !          1037: # particular file under consideration
        !          1038: class UnifiedDiffHandler < LineConsumer
        !          1039:   def setup
        !          1040:     @stats = UnifiedDiffStats.new
        !          1041:     @colour = UnifiedDiffColouriser.new
        !          1042:     @colour.output = @emailIO
        !          1043:     @lookahead = nil
        !          1044:   end
        !          1045: 
        !          1046:   def consume(line)
        !          1047:     case lineno()
        !          1048:      when 1
        !          1049:       @diffline = line
        !          1050:      when 2
        !          1051:       @lookahead = line
        !          1052:      when 3
        !          1053:       if $file.wants_diff_in_mail?
        !          1054:         @colour.start_output(@diffline, @lookahead, line)
        !          1055:       end
        !          1056:      else
        !          1057:       @stats.consume(line)
        !          1058:       if $file.wants_diff_in_mail?
        !          1059:         if $maxLinesPerDiff.nil? || @stats.diffLines < $maxLinesPerDiff
        !          1060:           @colour.consume(line)
        !          1061:         elsif @stats.diffLines == $maxLinesPerDiff
        !          1062:           @colour.consume(line)
        !          1063:           @colour.teardown
        !          1064:         end
        !          1065:       end
        !          1066:     end
        !          1067:   end
        !          1068: 
        !          1069:   def teardown
        !          1070:     if @lookahead == nil
        !          1071:       $file.isEmpty = true
        !          1072:     elsif @lookahead  =~ /Binary files .* and .* differ/
        !          1073:       $file.isBinary = true
        !          1074:     else
        !          1075:       if $file.wants_diff_in_mail?
        !          1076:         if $maxLinesPerDiff && @stats.diffLines > $maxLinesPerDiff
        !          1077:           println("</pre>")
        !          1078:           println("<strong class=\"error\">[truncated at #{$maxLinesPerDiff} lines; #{@stats.diffLines-$maxLinesPerDiff} more skipped]</strong>")
        !          1079:         else
        !          1080:           @colour.teardown
        !          1081:         end
        !          1082:         println("</div>") # end of "file" div
        !          1083:        $file.has_diff = true
        !          1084:       end
        !          1085:     end
        !          1086:   end
        !          1087: end
        !          1088: 
        !          1089: 
        !          1090: # a filter that counts the number of characters output to the underlying object
        !          1091: class OutputCounter
        !          1092:   # TODO: This should probably be a subclass of IO
        !          1093:   # TODO: assumes unix end-of-line convention
        !          1094: 
        !          1095:   def initialize(io)
        !          1096:     @io = io
        !          1097:     # TODO: use real number of chars representing end of line (for platform)
        !          1098:     @eol_size = 1
        !          1099:     @count = 0;
        !          1100:   end
        !          1101: 
        !          1102:   def puts(text)
        !          1103:     @count += text.length
        !          1104:     @count += @eol_size unless text =~ /\n$/
        !          1105:     @io.puts(text)
        !          1106:   end
        !          1107: 
        !          1108:   def print(text)
        !          1109:     @count += text.length
        !          1110:     @io.print(text)
        !          1111:   end
        !          1112: 
        !          1113:   attr_reader :count
        !          1114: end
        !          1115: 
        !          1116: 
        !          1117: # a filter that can be told to stop outputing data to the underlying object
        !          1118: class OutputDropper
        !          1119:   def initialize(io)
        !          1120:     @io = io
        !          1121:     @drop = false
        !          1122:   end
        !          1123: 
        !          1124:   def puts(text)
        !          1125:     @io.puts(text) unless @drop
        !          1126:   end
        !          1127: 
        !          1128:   def print(text)
        !          1129:     @io.print(text) unless @drop
        !          1130:   end
        !          1131: 
        !          1132:   attr_accessor :drop
        !          1133: end
        !          1134: 
        !          1135: 
        !          1136: # TODO: the current implementation of the size-limit continues to generate
        !          1137: # HTML-ified diff output, but doesn't add it to the email.  This means we
        !          1138: # can report 'what you would have won', but is less efficient than turning
        !          1139: # of the diff highlighting code.  Does this matter?
        !          1140: 
        !          1141: # Counts the amount of data written, and when choose_to_limit? is called,
        !          1142: # checks this count against the configured limit, discarding any further
        !          1143: # output if the limit is exceeded.  We aren't strict about the limit becase
        !          1144: # we don't want to chop-off the end of a tag and produce invalid HTML, etc.
        !          1145: class OutputSizeLimiter
        !          1146:   def initialize(io, limit)
        !          1147:     @dropper = OutputDropper.new(io)
        !          1148:     @counter = OutputCounter.new(@dropper)
        !          1149:     @limit = limit
        !          1150:     @written_count = nil
        !          1151:   end
        !          1152: 
        !          1153:   def puts(text)
        !          1154:     @counter.puts(text)
        !          1155:   end
        !          1156: 
        !          1157:   def print(text)
        !          1158:     @counter.print(text)
        !          1159:   end
        !          1160: 
        !          1161:   def choose_to_limit?
        !          1162:     return true if @dropper.drop
        !          1163:     if @counter.count >= @limit
        !          1164:       @dropper.drop = true
        !          1165:       @written_count = @counter.count
        !          1166:       return true
        !          1167:     end
        !          1168:     return false
        !          1169:   end
        !          1170: 
        !          1171:   def total_count
        !          1172:     @counter.count
        !          1173:   end
        !          1174: 
        !          1175:   def written_count
        !          1176:     if @written_count.nil?
        !          1177:       total_count
        !          1178:     else
        !          1179:       @written_count
        !          1180:     end
        !          1181:   end
        !          1182: end
        !          1183: 
        !          1184: # an RFC 822 email address
        !          1185: class EmailAddress
        !          1186:   def initialize(text)
        !          1187:     if text =~ /^\s*([^<]+?)\s*<\s*([^>]+?)\s*>\s*$/
        !          1188:       @personal_name = $1
        !          1189:       @address = $2
        !          1190:     else
        !          1191:       @personal_name = nil
        !          1192:       @address = text
        !          1193:     end
        !          1194:   end
        !          1195: 
        !          1196:   attr_accessor :personal_name, :address
        !          1197: 
        !          1198:   def has_personal_name?
        !          1199:     return !@personal_name.nil?
        !          1200:   end
        !          1201: 
        !          1202:   def encoded
        !          1203:     if has_personal_name?
        !          1204:       "#{encoded_personal_name} <#{address}>"
        !          1205:     else
        !          1206:       @address
        !          1207:     end
        !          1208:   end
        !          1209: 
        !          1210:   def to_s
        !          1211:     if has_personal_name?
        !          1212:       "#{personal_name} <#{address}>"
        !          1213:     else
        !          1214:       @address
        !          1215:     end
        !          1216:   end
        !          1217: 
        !          1218:   private
        !          1219: 
        !          1220:   def encoded_personal_name
        !          1221:     personal_name.split(" ").map{|word| encode_word(word)}.join(" ")
        !          1222:   end
        !          1223: 
        !          1224:   # rfc2047 encode the word, if it contains non-ASCII characters
        !          1225:   def encode_word(word)
        !          1226:     if $encoder.requires_rfc2047?(word)
        !          1227:       encoded = $encoder.marker_start_quoted
        !          1228:       $encoder.each_char_encoded(word) do |code|
        !          1229:        encoded << code
        !          1230:       end
        !          1231:       encoded << $encoder.marker_end_quoted
        !          1232:       return encoded
        !          1233:     end
        !          1234:     word
        !          1235:   end
        !          1236: end
        !          1237: 
        !          1238: 
        !          1239: cvsroot_dir = "#{ENV['CVSROOT']}/CVSROOT"
        !          1240: $config = "#{cvsroot_dir}/cvsspam.conf"
        !          1241: $users_file = "#{cvsroot_dir}/users"
        !          1242: 
        !          1243: $debug = false
        !          1244: $recipients = Array.new
        !          1245: $sendmail_prog = "/usr/sbin/sendmail"
        !          1246: $hostname = ENV['HOSTNAME'] || 'localhost'
        !          1247: $no_removed_file_diff = false
        !          1248: $no_added_file_diff = false
        !          1249: $no_diff = false
        !          1250: $task_keywords = ['TODO', 'FIXME']
        !          1251: $bugzillaURL = nil
        !          1252: $wikiURL = nil
        !          1253: $jiraURL = nil
        !          1254: $ticketURL = nil
        !          1255: $viewcvsURL = nil
        !          1256: $choraURL = nil
        !          1257: $cvswebURL = nil
        !          1258: $from_address = nil
        !          1259: $subjectPrefix = nil
        !          1260: $files_in_subject = false;
        !          1261: $smtp_host = nil
        !          1262: $repository_name = nil
        !          1263: # 2MiB limit on attached diffs,
        !          1264: $mail_size_limit = 1024 * 1024 * 2
        !          1265: $arg_charset = nil
        !          1266: 
        !          1267: require 'getoptlong'
        !          1268: 
        !          1269: opts = GetoptLong.new(
        !          1270:   [ "--to",     "-t", GetoptLong::REQUIRED_ARGUMENT ],
        !          1271:   [ "--config", "-c", GetoptLong::REQUIRED_ARGUMENT ],
        !          1272:   [ "--debug",  "-d", GetoptLong::NO_ARGUMENT ],
        !          1273:   [ "--from",   "-u", GetoptLong::REQUIRED_ARGUMENT ],
        !          1274:   [ "--charset",      GetoptLong::REQUIRED_ARGUMENT ]
        !          1275: )
        !          1276: 
        !          1277: opts.each do |opt, arg|
        !          1278:   $recipients << EmailAddress.new(arg) if opt=="--to"
        !          1279:   $config = arg if opt=="--config"
        !          1280:   $debug = true if opt=="--debug"
        !          1281:   $from_address = EmailAddress.new(arg) if opt=="--from"
        !          1282:   # must use different variable as the config is readed later.
        !          1283:   $arg_charset = arg if opt == "--charset"
        !          1284: end
        !          1285: 
        !          1286: 
        !          1287: if ARGV.length != 1
        !          1288:   if ARGV.length > 1
        !          1289:     $stderr.puts "extra arguments not needed: #{ARGV[1, ARGV.length-1].join(', ')}"
        !          1290:   else
        !          1291:     $stderr.puts "missing required file argument"
        !          1292:   end
        !          1293:   puts "Usage: cvsspam.rb [ --to <email> ] [ --config <file> ] <collect_diffs file>"
        !          1294:   exit(-1)
        !          1295: end
        !          1296: 
        !          1297: $logfile = ARGV[0]
        !          1298: 
        !          1299: 
        !          1300: $additionalHeaders = Array.new
        !          1301: $problemHeaders = Array.new
        !          1302: 
        !          1303: # helper function called from the 'config file'
        !          1304: def addHeader(name, value)
        !          1305:   if name =~ /^[!-9;-~]+$/
        !          1306:     $additionalHeaders << [name, value]
        !          1307:   else
        !          1308:     $problemHeaders << [name, value]
        !          1309:   end
        !          1310: end
        !          1311: # helper function called from the 'config file'
        !          1312: def addRecipient(email)
        !          1313:   $recipients << EmailAddress.new(email)
        !          1314: end
        !          1315: # 'constant' used from the 'config file'
        !          1316: class GUESS
        !          1317: end
        !          1318: 
        !          1319: if FileTest.exists?($config)
        !          1320:   blah("Using config '#{$config}'")
        !          1321:   load $config
        !          1322: else
        !          1323:   blah("Config file '#{$config}' not found, ignoring")
        !          1324: end
        !          1325: 
        !          1326: unless $arg_charset.nil?
        !          1327:   $charset = $arg_charset
        !          1328: end
        !          1329: 
        !          1330: if $recipients.empty?
        !          1331:   fail "No email recipients defined"
        !          1332: end
        !          1333: 
        !          1334: if $viewcvsURL != nil
        !          1335:   $viewcvsURL << "/" unless $viewcvsURL =~ /\/$/
        !          1336:   $frontend = ViewCVSFrontend.new($viewcvsURL)
        !          1337: elsif $choraURL !=nil
        !          1338:   $frontend = ChoraFrontend.new($choraURL)
        !          1339: elsif $cvswebURL !=nil
        !          1340:   $cvswebURL << "/" unless $cvswebURL =~ /\/$/
        !          1341:   $frontend = CVSwebFrontend.new($cvswebURL)
        !          1342: else
        !          1343:   $frontend = NoFrontend.new
        !          1344: end
        !          1345: 
        !          1346: if $viewcvsURL != nil || $cvswebURL !=nil
        !          1347:   if $repository_name == GUESS
        !          1348:     # use the last component of the repository path as the name
        !          1349:     ENV['CVSROOT'] =~ /([^\/]+$)/
        !          1350:     $frontend.repository_name = $1
        !          1351:   elsif $repository_name != nil
        !          1352:     $frontend.repository_name = $repository_name
        !          1353:   end
        !          1354: end
        !          1355: 
        !          1356: 
        !          1357: if $bugzillaURL != nil
        !          1358:   commentSubstitutions['\b[Bb][Uu][Gg]\s*#?[0-9]+'] = bugzillaSub
        !          1359: end
        !          1360: if $jiraURL != nil
        !          1361:   commentSubstitutions['\b[a-zA-Z]+-[0-9]+\b'] = jiraSub
        !          1362: end
        !          1363: if $ticketURL != nil
        !          1364:   commentSubstitutions['\b[Tt][Ii][Cc][Kk][Ee][Tt]\s*#?[0-9]+\b'] = ticketSub
        !          1365: end
        !          1366: if $wikiURL != nil
        !          1367:   commentSubstitutions['\[\[.+\]\]'] = wikiSub
        !          1368: end
        !          1369: $commentEncoder = MultiSub.new(commentSubstitutions)
        !          1370: 
        !          1371: 
        !          1372: tagHandler = TagHandler.new
        !          1373: 
        !          1374: $handlers = Hash[">" => CommentHandler.new,
        !          1375:                 "U" => UnifiedDiffHandler.new,
        !          1376:                 "T" => tagHandler,
        !          1377:                 "A" => AddedFileHandler.new,
        !          1378:                 "R" => RemovedFileHandler.new,
        !          1379:                 "M" => ModifiedFileHandler.new,
        !          1380:                 "V" => VersionHandler.new]
        !          1381: 
        !          1382: $handlers["A"].setTagHandler(tagHandler)
        !          1383: $handlers["R"].setTagHandler(tagHandler)
        !          1384: $handlers["M"].setTagHandler(tagHandler)
        !          1385: 
        !          1386: $fileEntries = Array.new
        !          1387: $task_list = Array.new
        !          1388: $allTags = Hash.new
        !          1389: 
        !          1390: File.open("#{$logfile}.emailtmp", File::RDWR|File::CREAT|File::TRUNC) do |mail|
        !          1391: 
        !          1392:   $diff_output_limiter = OutputSizeLimiter.new(mail, $mail_size_limit)
        !          1393: 
        !          1394:   File.open($logfile) do |log|
        !          1395:     reader = LogReader.new(log)
        !          1396: 
        !          1397:     until reader.eof
        !          1398:       handler = $handlers[reader.currentLineCode]
        !          1399:       if handler == nil
        !          1400:         raise "No handler file lines marked '##{reader.currentLineCode}'"
        !          1401:       end
        !          1402:       handler.handleLines(reader.getLines, $diff_output_limiter)
        !          1403:     end
        !          1404:   end
        !          1405: 
        !          1406: end
        !          1407: 
        !          1408: if $subjectPrefix == nil
        !          1409:    $subjectPrefix = "[CVS #{Repository.array.join(',')}]"
        !          1410: #  $subjectPrefix = "CVS update"
        !          1411: end
        !          1412: 
        !          1413:    $mailSubject = "#{Repository.array.join(',')}"
        !          1414: 
        !          1415: if $files_in_subject
        !          1416:   all_files = ""
        !          1417:   $fileEntries.each do |file|
        !          1418:     name = htmlEncode(file.name_after_common_prefix)
        !          1419:     if all_files != ""
        !          1420:       all_files = all_files + ";" + name
        !          1421:     else
        !          1422:       all_files = name
        !          1423:     end
        !          1424:   end
        !          1425:   $mailSubject = all_files + ":" + $mailSubject
        !          1426: end
        !          1427: 
        !          1428: mailSubject = "#{$subjectPrefix} #{$mailSubject}"
        !          1429: if mailSubject.length > $maxSubjectLength
        !          1430:   mailSubject = mailSubject[0, $maxSubjectLength]
        !          1431: end
        !          1432: 
        !          1433: $encoder = HeaderEncoder.new
        !          1434: # TODO: maybe we should use the system-default value instead of ISO Latin 1?
        !          1435: $encoder.charset = $charset.nil? ? "ISO-8859-1" : $charset
        !          1436: 
        !          1437: 
        !          1438: # generate the email header (and footer) having already generated the diffs
        !          1439: # for the email body to a temp file (which is simply included in the middle)
        !          1440: def make_html_email(mail)
        !          1441:   mail.puts(<<HEAD)
        !          1442: <html>
        !          1443: <head>
        !          1444: <style><!--
        !          1445:   body {background-color:#ffffff;}
        !          1446:   .file {border:1px solid #eeeeee;margin-top:1em;margin-bottom:1em;}
        !          1447:   .pathname {font-family:monospace; float:right;}
        !          1448:   .fileheader {margin-bottom:.5em;}
        !          1449:   .diff {margin:0;}
        !          1450:   .tasklist {padding:4px;border:1px dashed #000000;margin-top:1em;}
        !          1451:   .tasklist ul {margin-top:0;margin-bottom:0;}
        !          1452:   tr.alt {background-color:#eeeeee}
        !          1453:   #added {background-color:#ddffdd;}
        !          1454:   #addedchars {background-color:#99ff99;font-weight:bolder;}
        !          1455:   tr.alt #added {background-color:#ccf7cc;}
        !          1456:   #removed {background-color:#ffdddd;}
        !          1457:   #removedchars {background-color:#ff9999;font-weight:bolder;}
        !          1458:   tr.alt #removed {background-color:#f7cccc;}
        !          1459:   #info {color:#888888;}
        !          1460:   #context {background-color:#eeeeee;}
        !          1461:   td {padding-left:.3em;padding-right:.3em;}
        !          1462:   tr.head {border-bottom-width:1px;border-bottom-style:solid;}
        !          1463:   tr.head td {padding:0;padding-top:.2em;}
        !          1464:   .task {background-color:#ffff00;}
        !          1465:   .comment {white-space:-moz-pre-wrap;white-space:-pre-wrap;white-space:-o-pre-wrap;white-space:pre-wrap;word-wrap:break-word;padding:4px;border:1px dashed #000000;background-color:#ffffdd}
        !          1466:   .error {color:red;}
        !          1467:   hr {border-width:0px;height:2px;background:black;}
        !          1468: --></style>
        !          1469: </head>
        !          1470: <body>
        !          1471: HEAD
        !          1472: 
        !          1473:   unless ($problemHeaders.empty?)
        !          1474:     mail.puts("<strong class=\"error\">Bad header format in '#{$config}':<ul>")
        !          1475:     $stderr.puts("Bad header format in '#{$config}':")
        !          1476:     $problemHeaders.each do |header|
        !          1477:       mail.puts("<li><pre>#{htmlEncode(header[0])}</pre></li>")
        !          1478:       $stderr.puts(" - #{header[0]}")
        !          1479:     end
        !          1480:     mail.puts("</ul></strong>")
        !          1481:   end
        !          1482:   mail.puts("<table cellspacing=\"0\" cellpadding=\"0\" border=\"0\" rules=\"cols\">")
        !          1483: 
        !          1484:   haveTags = false
        !          1485:   Repository.each do |repository|
        !          1486:     haveTags |= repository.has_multiple_tags
        !          1487:   end
        !          1488: 
        !          1489:   filesAdded = 0
        !          1490:   filesRemoved = 0
        !          1491:   filesModified  = 0
        !          1492:   totalLinesAdded = 0
        !          1493:   totalLinesRemoved = 0
        !          1494:   file_count = 0
        !          1495:   lastPath = ""
        !          1496:   last_repository = nil
        !          1497:   $fileEntries.each do |file|
        !          1498:     unless file.repository == last_repository
        !          1499:       last_repository = file.repository
        !          1500:       mail.print("<tr class=\"head\"><td colspan=\"#{last_repository.has_multiple_tags ? 5 : 4}\">")
        !          1501:       if last_repository.has_multiple_tags
        !          1502:         mail.print("Mixed-tag commit")
        !          1503:       else
        !          1504:         mail.print("Commit")
        !          1505:       end
        !          1506:       mail.print(" in <b><tt>#{htmlEncode(last_repository.common_prefix)}</tt></b>")
        !          1507:       if last_repository.trunk_only?
        !          1508:         mail.print("<span id=\"info\"> on MAIN</span>")
        !          1509:       else
        !          1510:         mail.print(" on ")
        !          1511:         tagCount = 0
        !          1512:         last_repository.each_tag do |tag|
        !          1513:           tagCount += 1
        !          1514:           if tagCount > 1
        !          1515:             mail.print tagCount<last_repository.tag_count ? ", " : " &amp; "
        !          1516:           end
        !          1517:           mail.print tag ? htmlEncode(tag) : "<span id=\"info\">MAIN</span>"
        !          1518:         end
        !          1519:       end
        !          1520:       mail.puts("</td></tr>")
        !          1521:     end
        !          1522:     file_count += 1
        !          1523:     if (file_count%2==0)
        !          1524:       mail.print("<tr class=\"alt\">")
        !          1525:     else
        !          1526:       mail.print("<tr>")
        !          1527:     end
        !          1528:     if file.addition?
        !          1529:       filesAdded += 1
        !          1530:     elsif file.removal?
        !          1531:       filesRemoved += 1
        !          1532:     elsif file.modification?
        !          1533:       filesModified += 1
        !          1534:     end
        !          1535:     name = htmlEncode(file.name_after_common_prefix)
        !          1536:     slashPos = name.rindex("/")
        !          1537:     if slashPos==nil
        !          1538:       prefix = ""
        !          1539:     else
        !          1540:       thisPath = name[0,slashPos]
        !          1541:       name = name[slashPos+1,name.length]
        !          1542:       if thisPath == lastPath
        !          1543:         prefix = "&nbsp;"*(slashPos) + "/"
        !          1544:       else 
        !          1545:         prefix = thisPath + "/"
        !          1546:       end
        !          1547:       lastPath = thisPath
        !          1548:     end
        !          1549:     if file.addition?
        !          1550:       name = "<span id=\"added\">#{name}</span>"
        !          1551:     elsif file.removal?
        !          1552:       name = "<span id=\"removed\">#{name}</span>"
        !          1553:     end
        !          1554:     if file.has_diff?
        !          1555:       mail.print("<td><tt>#{prefix}<a href=\"#file#{file_count}\">#{name}</a></tt></td>")
        !          1556:     else
        !          1557:       mail.print("<td><tt>#{prefix}#{name}</tt></td>")
        !          1558:     end
        !          1559:     if file.isEmpty
        !          1560:       mail.print("<td colspan=\"2\" align=\"center\"><small id=\"info\">[empty]</small></td>")
        !          1561:     elsif file.isBinary
        !          1562:       mail.print("<td colspan=\"2\" align=\"center\"><small id=\"info\">[binary]</small></td>")
        !          1563:     else
        !          1564:       if file.lineAdditions>0
        !          1565:         totalLinesAdded += file.lineAdditions
        !          1566:         mail.print("<td align=\"right\" id=\"added\">+#{file.lineAdditions}</td>")
        !          1567:       else
        !          1568:         mail.print("<td></td>")
        !          1569:       end
        !          1570:       if file.lineRemovals>0
        !          1571:         totalLinesRemoved += file.lineRemovals
        !          1572:         mail.print("<td align=\"right\" id=\"removed\">-#{file.lineRemovals}</td>")
        !          1573:       else
        !          1574:         mail.print("<td></td>")
        !          1575:       end
        !          1576:     end
        !          1577:     if last_repository.has_multiple_tags
        !          1578:       if file.tag
        !          1579:         mail.print("<td>#{htmlEncode(file.tag)}</td>")
        !          1580:       else
        !          1581:         mail.print("<td><span id=\"info\">MAIN</span></td>")
        !          1582:       end
        !          1583:     elsif haveTags
        !          1584:       mail.print("<td></td>")
        !          1585:     end
        !          1586:     if file.addition?
        !          1587:       mail.print("<td nowrap=\"nowrap\" align=\"right\">added #{$frontend.version(file.path,file.toVer)}</td>")
        !          1588:     elsif file.removal?
        !          1589:       mail.print("<td nowrap=\"nowrap\">#{$frontend.version(file.path,file.fromVer)} removed</td>")
        !          1590:     elsif file.modification?
        !          1591:       mail.print("<td nowrap=\"nowrap\" align=\"center\">#{$frontend.version(file.path,file.fromVer)} #{$frontend.diff(file)} #{$frontend.version(file.path,file.toVer)}</td>")
        !          1592:     end
        !          1593: 
        !          1594:     mail.puts("</tr>")
        !          1595:   end
        !          1596:   if $fileEntries.size>1 && (totalLinesAdded+totalLinesRemoved)>0
        !          1597:     # give total number of lines added/removed accross all files
        !          1598:     mail.print("<tr><td></td>")
        !          1599:     if totalLinesAdded>0
        !          1600:       mail.print("<td align=\"right\" id=\"added\">+#{totalLinesAdded}</td>")
        !          1601:     else
        !          1602:       mail.print("<td></td>")
        !          1603:     end
        !          1604:     if totalLinesRemoved>0
        !          1605:       mail.print("<td align=\"right\" id=\"removed\">-#{totalLinesRemoved}</td>")
        !          1606:     else
        !          1607:       mail.print("<td></td>")
        !          1608:     end
        !          1609:     mail.print("<td></td>") if haveTags
        !          1610:     mail.puts("<td></td></tr>")
        !          1611:   end
        !          1612:   
        !          1613:   mail.puts("</table>")
        !          1614: 
        !          1615:   totalFilesChanged = filesAdded+filesRemoved+filesModified
        !          1616:   if totalFilesChanged > 1
        !          1617:     mail.print("<small id=\"info\">")
        !          1618:     changeKind = 0
        !          1619:     if filesAdded>0
        !          1620:       mail.print("#{filesAdded} added")
        !          1621:       changeKind += 1
        !          1622:     end
        !          1623:     if filesRemoved>0
        !          1624:       mail.print(" + ") if changeKind>0
        !          1625:       mail.print("#{filesRemoved} removed")
        !          1626:       changeKind += 1
        !          1627:     end
        !          1628:     if filesModified>0
        !          1629:       mail.print(" + ") if changeKind>0
        !          1630:       mail.print("#{filesModified} modified")
        !          1631:       changeKind += 1
        !          1632:     end
        !          1633:     mail.print(", total #{totalFilesChanged}") if changeKind > 1
        !          1634:     mail.puts(" files</small><br />")
        !          1635:   end
        !          1636: 
        !          1637:   if $task_list.size > 0
        !          1638:     task_count = 0
        !          1639:     mail.puts("<div class=\"tasklist\"><ul>")
        !          1640:     $task_list.each do |item|
        !          1641:       task_count += 1
        !          1642:       item = htmlEncode(item)
        !          1643:       mail.puts("<li><a href=\"#task#{task_count}\">#{item}</a></li>")
        !          1644:     end
        !          1645:     mail.puts("</ul></div>")
        !          1646:   end
        !          1647: 
        !          1648: 
        !          1649:   File.open("#{$logfile}.emailtmp") do |input|
        !          1650:     input.each do |line|
        !          1651:       mail.puts(line.chomp)
        !          1652:     end
        !          1653:   end
        !          1654:   if $diff_output_limiter.choose_to_limit?
        !          1655:     mail.puts("<p><strong class=\"error\">[Reached #{$diff_output_limiter.written_count} bytes of diffs.")
        !          1656:     mail.puts("Since the limit is about #{$mail_size_limit} bytes,")
        !          1657:     mail.puts("a further #{$diff_output_limiter.total_count-$diff_output_limiter.written_count} were skipped.]</strong></p>")
        !          1658:   end
        !          1659:   if $debug
        !          1660:     blah("leaving file #{$logfile}.emailtmp")
        !          1661:   else
        !          1662:     File.unlink("#{$logfile}.emailtmp")
        !          1663:   end
        !          1664: 
        !          1665:   mail.puts("<center><small><a href=\"http://www.badgers-in-foil.co.uk/projects/cvsspam/\" title=\"commit -&gt; email\">CVSspam</a> #{$version}</small></center>")
        !          1666: 
        !          1667:   mail.puts("</body></html>")
        !          1668: 
        !          1669: end
        !          1670: 
        !          1671: # Tries to look up an 'alias' email address for the given string in the
        !          1672: # CVSROOT/users file, if the file exists.  The argument is returned unchanged
        !          1673: # if no alias is found.
        !          1674: def sender_alias(email)
        !          1675:   if File.exists?($users_file)
        !          1676:     File.open($users_file) do |io|
        !          1677:       io.each_line do |line|
        !          1678:         if line =~ /^([^:]+)\s*:\s*(['"]?)([^\n\r]+)(\2)/
        !          1679:           if email.address == $1
        !          1680:             return EmailAddress.new($3)
        !          1681:           end
        !          1682:         end
        !          1683:       end
        !          1684:     end
        !          1685:   end
        !          1686:   email
        !          1687: end
        !          1688: 
        !          1689: # A handle for code that needs to add headers and a body to an email being
        !          1690: # sent.  This wraps an underlying IO object, and is responsible for doing
        !          1691: # sensible header formatting, and for ensuring that the body is seperated
        !          1692: # from the message headers by a blank line (as it is required to be).
        !          1693: class MailContext
        !          1694:   def initialize(io)
        !          1695:     @done_headers = false
        !          1696:     @io = io
        !          1697:   end
        !          1698: 
        !          1699:   # add a header to the email.  raises an exception if #body has already been
        !          1700:   # called
        !          1701:   def header(name, value)
        !          1702:     raise "headers already commited" if @done_headers
        !          1703:     if name == "Subject"
        !          1704:       $encoder.encode_header(@io, "Subject", value)
        !          1705:     else
        !          1706:       @io.puts("#{name}: #{value}")
        !          1707:     end
        !          1708:   end
        !          1709: 
        !          1710:   # yields an IO that should be used to write the message body
        !          1711:   def body
        !          1712:     @done_headers = true
        !          1713:     @io.puts
        !          1714:     yield @io
        !          1715:   end
        !          1716: end
        !          1717: 
        !          1718: # provides a send() method for sending email by invoking the 'sendmail'
        !          1719: # command-line program
        !          1720: class SendmailMailer
        !          1721:   def send(from, recipients)
        !          1722:     # The -t option causes sendmail to take message headers, as well as the
        !          1723:     # message body, from its input.  The -oi option stops a dot on a line on
        !          1724:     # its own from being interpreted as the end of the message body (so
        !          1725:     # messages that have such a line don't fail part-way though sending),
        !          1726:     cmd = "#{$sendmail_prog} -t -oi"
        !          1727:     blah("invoking '#{cmd}'")
        !          1728:     IO.popen(cmd, "w") do |mail|
        !          1729:       ctx = MailContext.new(mail) 
        !          1730:       ctx.header("To", recipients.map{|addr| addr.encoded}.join(','))
        !          1731:       if from
        !          1732:         blah("Mail From: <#{from}>")
        !          1733:       else
        !          1734:         blah("Mail From not set")
        !          1735:       end
        !          1736:       ctx.header("From", from.encoded) if from
        !          1737:       yield ctx
        !          1738:     end
        !          1739:   end
        !          1740: end
        !          1741: #
        !          1742: # provides a send() method for sending email by connecting to an SMTP server
        !          1743: # using the Ruby Net::SMTP package.
        !          1744: class SMTPMailer
        !          1745:   def initialize(smtp_host)
        !          1746:     @smtp_host = smtp_host
        !          1747:   end
        !          1748: 
        !          1749:   class IOAdapter
        !          1750:     def initialize(mail)
        !          1751:       @mail = mail
        !          1752:     end
        !          1753:     def puts(text="")
        !          1754:       @mail.write(text)
        !          1755:       @mail.write("\r\n")
        !          1756:     end
        !          1757:     def print(text)
        !          1758:       @mail.write(text)
        !          1759:     end
        !          1760:   end
        !          1761: 
        !          1762:   def send(from, recipients)
        !          1763:     if from == nil
        !          1764:       from = EmailAddress.new(ENV['USER'] || ENV['USERNAME'] || 'cvsspam')
        !          1765:     end  
        !          1766:     unless from.address =~ /@/
        !          1767:       from.address = "#{from.address}@#{$hostname}"
        !          1768:     end
        !          1769:     smtp = Net::SMTP.new(@smtp_host)
        !          1770:     blah("connecting to '#{@smtp_host}'")
        !          1771:     smtp.start()
        !          1772:     smtp.ready(from.address, recipients.map{|addr| addr.address}) do |mail|
        !          1773:       ctx = MailContext.new(IOAdapter.new(mail))
        !          1774:       ctx.header("To", recipients.map{|addr| addr.encoded}.join(','))
        !          1775:       blah("Mail From: <#{from}>")
        !          1776:       ctx.header("From", from.encoded) if from
        !          1777:       ctx.header("Date", Time.now.utc.strftime(DATE_HEADER_FORMAT))
        !          1778:       yield ctx
        !          1779:     end
        !          1780:   end
        !          1781: end
        !          1782: 
        !          1783: 
        !          1784: def make_msg_id(localpart, hostpart)
        !          1785:   "<cvsspam-#{localpart}@#{hostpart}>"
        !          1786: end
        !          1787: 
        !          1788: 
        !          1789: # replaces control characters, and a selection of other characters that
        !          1790: # may not appear unquoted in an RFC822 'word', with underscores.  (It
        !          1791: # doesn't actually zap '.' though.)
        !          1792: def zap_header_special_chars(text)
        !          1793:   text.gsub(/<>()\[\]@,;:\\[\000-\037\177]/, "_")
        !          1794: end
        !          1795: 
        !          1796: 
        !          1797: # Mail clients will try to 'thread' together a conversation over
        !          1798: # several email messages by inspecting the In-Reply-To and References headers,
        !          1799: # which should refer to previous emails in the conversation by mentioning
        !          1800: # the value of the previous message's Message-Id header.  This function invents
        !          1801: # values for these headers so that, in the special case where a *single* file
        !          1802: # is committed to repeatedly, the emails giving notification of these commits
        !          1803: # can be threaded together automatically by the mail client.
        !          1804: def inject_threading_headers(mail)
        !          1805:   return unless $fileEntries.length == 1
        !          1806:   file = $fileEntries[0]
        !          1807:   name = zap_header_special_chars(file.path)
        !          1808:   unless file.fromVer == "NONE"
        !          1809:     mail.header("References", make_msg_id("#{name}.#{file.fromVer}", $hostname))
        !          1810:   end
        !          1811:   unless file.toVer == "NONE"
        !          1812:     mail.header("Message-ID", make_msg_id("#{name}.#{file.toVer}", $hostname))
        !          1813:   end
        !          1814: end
        !          1815: 
        !          1816: 
        !          1817: if $smtp_host
        !          1818:   require 'net/smtp'
        !          1819:   mailer = SMTPMailer.new($smtp_host)
        !          1820: else
        !          1821:   mailer = SendmailMailer.new
        !          1822: end
        !          1823: 
        !          1824: $from_address = sender_alias($from_address) unless $from_address.nil?
        !          1825: 
        !          1826: mailer.send($from_address, $recipients) do |mail|
        !          1827:   mail.header("Subject", mailSubject)
        !          1828:   inject_threading_headers(mail)
        !          1829:   mail.header("MIME-Version", "1.0")
        !          1830:   mail.header("Content-Type", "text/html" + ($charset.nil? ? "" : "; charset=\"#{$charset}\""))
        !          1831:   if ENV['REMOTE_HOST']
        !          1832:     # TODO: I think this will always be an IP address.  If a hostname is
        !          1833:     # possible, it may need encoding of some kind,
        !          1834:     mail.header("X-Originating-IP", "[#{ENV['REMOTE_HOST']}]")
        !          1835:   end
        !          1836:   unless ($additionalHeaders.empty?)
        !          1837:     $additionalHeaders.each do |header|
        !          1838:       mail.header(header[0], header[1])
        !          1839:     end
        !          1840:   end
        !          1841:   mail.header("X-Mailer", "CVSspam #{$version} <http://www.badgers-in-foil.co.uk/projects/cvsspam/>")
        !          1842: 
        !          1843:   mail.body do |body|
        !          1844:     make_html_email(body)
        !          1845:   end
        !          1846: end

FreeBSD-CVSweb <freebsd-cvsweb@FreeBSD.org>