##// END OF EJS Templates
Better handle html-only emails (#16962)....
Jean-Philippe Lang -
r13931:3ae42cb32617
parent child
Show More
@@ -0,0 +1,54
1 # Redmine - project management software
2 # Copyright (C) 2006-2015 Jean-Philippe Lang
3 #
4 # This program is free software; you can redistribute it and/or
5 # modify it under the terms of the GNU General Public License
6 # as published by the Free Software Foundation; either version 2
7 # of the License, or (at your option) any later version.
8 #
9 # This program is distributed in the hope that it will be useful,
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 # GNU General Public License for more details.
13 #
14 # You should have received a copy of the GNU General Public License
15 # along with this program; if not, write to the Free Software
16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
17
18 require 'loofah/helpers'
19
20 module Redmine
21 module WikiFormatting
22 class HtmlParser
23
24 class_attribute :tags
25 self.tags = {
26 'br' => {:post => "\n"}
27 }
28
29 def self.to_text(html)
30 html = html.gsub(/[\n\r]/, '').squeeze(' ')
31
32 doc = Loofah.document(html)
33 doc.scrub!(WikiTags.new(tags))
34 doc.scrub!(:newline_block_elements)
35
36 Loofah::Helpers.remove_extraneous_whitespace(doc.text).strip
37 end
38
39 class WikiTags < ::Loofah::Scrubber
40 def initialize(tags_to_text)
41 @direction = :bottom_up
42 @tags_to_text = tags_to_text || {}
43 end
44
45 def scrub(node)
46 formatting = @tags_to_text[node.name]
47 return CONTINUE unless formatting
48 node.add_next_sibling Nokogiri::XML::Text.new("#{formatting[:pre]}#{node.content}#{formatting[:post]}", node.document)
49 node.remove
50 end
51 end
52 end
53 end
54 end
@@ -0,0 +1,40
1 # Redmine - project management software
2 # Copyright (C) 2006-2015 Jean-Philippe Lang
3 #
4 # This program is free software; you can redistribute it and/or
5 # modify it under the terms of the GNU General Public License
6 # as published by the Free Software Foundation; either version 2
7 # of the License, or (at your option) any later version.
8 #
9 # This program is distributed in the hope that it will be useful,
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 # GNU General Public License for more details.
13 #
14 # You should have received a copy of the GNU General Public License
15 # along with this program; if not, write to the Free Software
16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
17
18 module Redmine
19 module WikiFormatting
20 module Markdown
21 class HtmlParser < Redmine::WikiFormatting::HtmlParser
22
23 self.tags = {
24 'b' => {:pre => '**', :post => '**'},
25 'strong' => {:pre => '**', :post => '**'},
26 'i' => {:pre => '_', :post => '_'},
27 'em' => {:pre => '_', :post => '_'},
28 'strike' => {:pre => '~~', :post => '~~'},
29 'br' => {:post => "\n"},
30 'h1' => {:pre => "\n\n# ", :post => "\n\n"},
31 'h2' => {:pre => "\n\n## ", :post => "\n\n"},
32 'h3' => {:pre => "\n\n### ", :post => "\n\n"},
33 'h4' => {:pre => "\n\n#### ", :post => "\n\n"},
34 'h5' => {:pre => "\n\n##### ", :post => "\n\n"},
35 'h6' => {:pre => "\n\n###### ", :post => "\n\n"}
36 }
37 end
38 end
39 end
40 end
@@ -0,0 +1,41
1 # Redmine - project management software
2 # Copyright (C) 2006-2015 Jean-Philippe Lang
3 #
4 # This program is free software; you can redistribute it and/or
5 # modify it under the terms of the GNU General Public License
6 # as published by the Free Software Foundation; either version 2
7 # of the License, or (at your option) any later version.
8 #
9 # This program is distributed in the hope that it will be useful,
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 # GNU General Public License for more details.
13 #
14 # You should have received a copy of the GNU General Public License
15 # along with this program; if not, write to the Free Software
16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
17
18 module Redmine
19 module WikiFormatting
20 module Textile
21 class HtmlParser < Redmine::WikiFormatting::HtmlParser
22
23 self.tags = {
24 'b' => {:pre => '*', :post => '*'},
25 'strong' => {:pre => '*', :post => '*'},
26 'i' => {:pre => '_', :post => '_'},
27 'em' => {:pre => '_', :post => '_'},
28 'u' => {:pre => '+', :post => '+'},
29 'strike' => {:pre => '-', :post => '-'},
30 'br' => {:post => "\n"},
31 'h1' => {:pre => "\n\nh1. ", :post => "\n\n"},
32 'h2' => {:pre => "\n\nh2. ", :post => "\n\n"},
33 'h3' => {:pre => "\n\nh3. ", :post => "\n\n"},
34 'h4' => {:pre => "\n\nh4. ", :post => "\n\n"},
35 'h5' => {:pre => "\n\nh5. ", :post => "\n\n"},
36 'h6' => {:pre => "\n\nh6. ", :post => "\n\n"}
37 }
38 end
39 end
40 end
41 end
This diff has been collapsed as it changes many lines, (966 lines changed) Show them Hide them
@@ -0,0 +1,966
1 From: jsmith@somenet.foo
2 To: testuser@example.org
3 Subject: =?utf-8?Q?Test_email?=
4 Date: Mon, 11 May 2015 10:50:31 -0500
5 MIME-Version: 1.0
6 Content-Type: multipart/alternative;
7 boundary="Mark=_539924359269962179476"
8 X-Priority: 3
9
10 This is a multi-part message in MIME format.
11
12 --Mark=_539924359269962179476
13 Content-Type: text/plain;
14 charset="utf-8"
15 Content-Transfer-Encoding: quoted-printable
16
17 Simple, unadorned test email generated by Outlook 2010. It is in HTML f=
18 ormat, but no special formatting has been chosen. I=E2=80=99m going to =
19 save this as a draft and then manually drop it into the Inbox for scrap=
20 ing by Redmine 3.0.2.
21
22 --Mark=_539924359269962179476
23 Content-Type: text/html;
24 charset="utf-8"
25 Content-Transfer-Encoding: quoted-printable
26
27 <STYLE>
28 pre {
29 white-space: pre-wrap; /* css-3 */
30 white-space: -moz-pre-wrap !important; /* Mozilla, since 1999 */
31 white-space: -pre-wrap; /* Opera 4-6 */
32 white-space: -o-pre-wrap; /* Opera 7 */
33 word-wrap: break-word; /* Internet Explorer 5.5+ */
34 }
35 </STYLE>
36 <html xmlns:v=3D"urn:schemas-microsoft-com:vml" xmlns:o=3D"urn:schemas-=
37 microsoft-com:office:office" xmlns:w=3D"urn:schemas-microsoft-com:offic=
38 e:word" xmlns:m=3D"http://schemas.microsoft.com/office/2004/12/omml" xm=
39 lns=3D"http://www.w3.org/TR/REC-html40"><head><meta name=3DProgId conte=
40 nt=3DWord.Document><meta name=3DGenerator content=3D"Microsoft Word 15"=
41 ><meta name=3DOriginator content=3D"Microsoft Word 15"><link rel=3DFile=
42 -List href=3D"cid:filelist.xml@01D08BD8.4D051580"><!--[if gte mso 9]><x=
43 ml>
44 <o:OfficeDocumentSettings>
45 <o:AllowPNG/>
46 </o:OfficeDocumentSettings>
47 </xml><![endif]--><link rel=3DthemeData href=3D"~~themedata~~"><link re=
48 l=3DcolorSchemeMapping href=3D"~~colorschememapping~~"><!--[if gte mso =
49 9]><xml>
50 <w:WordDocument>
51 <w:Zoom>120</w:Zoom>
52 <w:SpellingState>Clean</w:SpellingState>
53 <w:TrackMoves/>
54 <w:TrackFormatting/>
55 <w:EnvelopeVis/>
56 <w:PunctuationKerning/>
57 <w:ValidateAgainstSchemas/>
58 <w:SaveIfXMLInvalid>false</w:SaveIfXMLInvalid>
59 <w:IgnoreMixedContent>false</w:IgnoreMixedContent>
60 <w:AlwaysShowPlaceholderText>false</w:AlwaysShowPlaceholderText>
61 <w:DoNotPromoteQF/>
62 <w:LidThemeOther>EN-US</w:LidThemeOther>
63 <w:LidThemeAsian>X-NONE</w:LidThemeAsian>
64 <w:LidThemeComplexScript>X-NONE</w:LidThemeComplexScript>
65 <w:Compatibility>
66 <w:DoNotExpandShiftReturn/>
67 <w:BreakWrappedTables/>
68 <w:SnapToGridInCell/>
69 <w:WrapTextWithPunct/>
70 <w:UseAsianBreakRules/>
71 <w:DontGrowAutofit/>
72 <w:SplitPgBreakAndParaMark/>
73 <w:EnableOpenTypeKerning/>
74 <w:DontFlipMirrorIndents/>
75 <w:OverrideTableStyleHps/>
76 </w:Compatibility>
77 <w:BrowserLevel>MicrosoftInternetExplorer4</w:BrowserLevel>
78 <m:mathPr>
79 <m:mathFont m:val=3D"Cambria Math"/>
80 <m:brkBin m:val=3D"before"/>
81 <m:brkBinSub m:val=3D"&#45;-"/>
82 <m:smallFrac m:val=3D"off"/>
83 <m:dispDef/>
84 <m:lMargin m:val=3D"0"/>
85 <m:rMargin m:val=3D"0"/>
86 <m:defJc m:val=3D"centerGroup"/>
87 <m:wrapIndent m:val=3D"1440"/>
88 <m:intLim m:val=3D"subSup"/>
89 <m:naryLim m:val=3D"undOvr"/>
90 </m:mathPr></w:WordDocument>
91 </xml><![endif]--><!--[if gte mso 9]><xml>
92 <w:LatentStyles DefLockedState=3D"false" DefUnhideWhenUsed=3D"false" De=
93 fSemiHidden=3D"false" DefQFormat=3D"false" DefPriority=3D"99" LatentSty=
94 leCount=3D"371">
95 <w:LsdException Locked=3D"false" Priority=3D"0" QFormat=3D"true" Name=3D=
96 "Normal"/>
97 <w:LsdException Locked=3D"false" Priority=3D"9" QFormat=3D"true" Name=3D=
98 "heading 1"/>
99 <w:LsdException Locked=3D"false" Priority=3D"9" SemiHidden=3D"true" Unh=
100 ideWhenUsed=3D"true" QFormat=3D"true" Name=3D"heading 2"/>
101 <w:LsdException Locked=3D"false" Priority=3D"9" SemiHidden=3D"true" Unh=
102 ideWhenUsed=3D"true" QFormat=3D"true" Name=3D"heading 3"/>
103 <w:LsdException Locked=3D"false" Priority=3D"9" SemiHidden=3D"true" Unh=
104 ideWhenUsed=3D"true" QFormat=3D"true" Name=3D"heading 4"/>
105 <w:LsdException Locked=3D"false" Priority=3D"9" SemiHidden=3D"true" Unh=
106 ideWhenUsed=3D"true" QFormat=3D"true" Name=3D"heading 5"/>
107 <w:LsdException Locked=3D"false" Priority=3D"9" SemiHidden=3D"true" Unh=
108 ideWhenUsed=3D"true" QFormat=3D"true" Name=3D"heading 6"/>
109 <w:LsdException Locked=3D"false" Priority=3D"9" SemiHidden=3D"true" Unh=
110 ideWhenUsed=3D"true" QFormat=3D"true" Name=3D"heading 7"/>
111 <w:LsdException Locked=3D"false" Priority=3D"9" SemiHidden=3D"true" Unh=
112 ideWhenUsed=3D"true" QFormat=3D"true" Name=3D"heading 8"/>
113 <w:LsdException Locked=3D"false" Priority=3D"9" SemiHidden=3D"true" Unh=
114 ideWhenUsed=3D"true" QFormat=3D"true" Name=3D"heading 9"/>
115 <w:LsdException Locked=3D"false" SemiHidden=3D"true" UnhideWhenUsed=3D"=
116 true" Name=3D"index 1"/>
117 <w:LsdException Locked=3D"false" SemiHidden=3D"true" UnhideWhenUsed=3D"=
118 true" Name=3D"index 2"/>
119 <w:LsdException Locked=3D"false" SemiHidden=3D"true" UnhideWhenUsed=3D"=
120 true" Name=3D"index 3"/>
121 <w:LsdException Locked=3D"false" SemiHidden=3D"true" UnhideWhenUsed=3D"=
122 true" Name=3D"index 4"/>
123 <w:LsdException Locked=3D"false" SemiHidden=3D"true" UnhideWhenUsed=3D"=
124 true" Name=3D"index 5"/>
125 <w:LsdException Locked=3D"false" SemiHidden=3D"true" UnhideWhenUsed=3D"=
126 true" Name=3D"index 6"/>
127 <w:LsdException Locked=3D"false" SemiHidden=3D"true" UnhideWhenUsed=3D"=
128 true" Name=3D"index 7"/>
129 <w:LsdException Locked=3D"false" SemiHidden=3D"true" UnhideWhenUsed=3D"=
130 true" Name=3D"index 8"/>
131 <w:LsdException Locked=3D"false" SemiHidden=3D"true" UnhideWhenUsed=3D"=
132 true" Name=3D"index 9"/>
133 <w:LsdException Locked=3D"false" Priority=3D"39" SemiHidden=3D"true" Un=
134 hideWhenUsed=3D"true" Name=3D"toc 1"/>
135 <w:LsdException Locked=3D"false" Priority=3D"39" SemiHidden=3D"true" Un=
136 hideWhenUsed=3D"true" Name=3D"toc 2"/>
137 <w:LsdException Locked=3D"false" Priority=3D"39" SemiHidden=3D"true" Un=
138 hideWhenUsed=3D"true" Name=3D"toc 3"/>
139 <w:LsdException Locked=3D"false" Priority=3D"39" SemiHidden=3D"true" Un=
140 hideWhenUsed=3D"true" Name=3D"toc 4"/>
141 <w:LsdException Locked=3D"false" Priority=3D"39" SemiHidden=3D"true" Un=
142 hideWhenUsed=3D"true" Name=3D"toc 5"/>
143 <w:LsdException Locked=3D"false" Priority=3D"39" SemiHidden=3D"true" Un=
144 hideWhenUsed=3D"true" Name=3D"toc 6"/>
145 <w:LsdException Locked=3D"false" Priority=3D"39" SemiHidden=3D"true" Un=
146 hideWhenUsed=3D"true" Name=3D"toc 7"/>
147 <w:LsdException Locked=3D"false" Priority=3D"39" SemiHidden=3D"true" Un=
148 hideWhenUsed=3D"true" Name=3D"toc 8"/>
149 <w:LsdException Locked=3D"false" Priority=3D"39" SemiHidden=3D"true" Un=
150 hideWhenUsed=3D"true" Name=3D"toc 9"/>
151 <w:LsdException Locked=3D"false" SemiHidden=3D"true" UnhideWhenUsed=3D"=
152 true" Name=3D"Normal Indent"/>
153 <w:LsdException Locked=3D"false" SemiHidden=3D"true" UnhideWhenUsed=3D"=
154 true" Name=3D"footnote text"/>
155 <w:LsdException Locked=3D"false" SemiHidden=3D"true" UnhideWhenUsed=3D"=
156 true" Name=3D"annotation text"/>
157 <w:LsdException Locked=3D"false" SemiHidden=3D"true" UnhideWhenUsed=3D"=
158 true" Name=3D"header"/>
159 <w:LsdException Locked=3D"false" SemiHidden=3D"true" UnhideWhenUsed=3D"=
160 true" Name=3D"footer"/>
161 <w:LsdException Locked=3D"false" SemiHidden=3D"true" UnhideWhenUsed=3D"=
162 true" Name=3D"index heading"/>
163 <w:LsdException Locked=3D"false" Priority=3D"35" SemiHidden=3D"true" Un=
164 hideWhenUsed=3D"true" QFormat=3D"true" Name=3D"caption"/>
165 <w:LsdException Locked=3D"false" SemiHidden=3D"true" UnhideWhenUsed=3D"=
166 true" Name=3D"table of figures"/>
167 <w:LsdException Locked=3D"false" SemiHidden=3D"true" UnhideWhenUsed=3D"=
168 true" Name=3D"envelope address"/>
169 <w:LsdException Locked=3D"false" SemiHidden=3D"true" UnhideWhenUsed=3D"=
170 true" Name=3D"envelope return"/>
171 <w:LsdException Locked=3D"false" SemiHidden=3D"true" UnhideWhenUsed=3D"=
172 true" Name=3D"footnote reference"/>
173 <w:LsdException Locked=3D"false" SemiHidden=3D"true" UnhideWhenUsed=3D"=
174 true" Name=3D"annotation reference"/>
175 <w:LsdException Locked=3D"false" SemiHidden=3D"true" UnhideWhenUsed=3D"=
176 true" Name=3D"line number"/>
177 <w:LsdException Locked=3D"false" SemiHidden=3D"true" UnhideWhenUsed=3D"=
178 true" Name=3D"page number"/>
179 <w:LsdException Locked=3D"false" SemiHidden=3D"true" UnhideWhenUsed=3D"=
180 true" Name=3D"endnote reference"/>
181 <w:LsdException Locked=3D"false" SemiHidden=3D"true" UnhideWhenUsed=3D"=
182 true" Name=3D"endnote text"/>
183 <w:LsdException Locked=3D"false" SemiHidden=3D"true" UnhideWhenUsed=3D"=
184 true" Name=3D"table of authorities"/>
185 <w:LsdException Locked=3D"false" SemiHidden=3D"true" UnhideWhenUsed=3D"=
186 true" Name=3D"macro"/>
187 <w:LsdException Locked=3D"false" SemiHidden=3D"true" UnhideWhenUsed=3D"=
188 true" Name=3D"toa heading"/>
189 <w:LsdException Locked=3D"false" SemiHidden=3D"true" UnhideWhenUsed=3D"=
190 true" Name=3D"List"/>
191 <w:LsdException Locked=3D"false" SemiHidden=3D"true" UnhideWhenUsed=3D"=
192 true" Name=3D"List Bullet"/>
193 <w:LsdException Locked=3D"false" SemiHidden=3D"true" UnhideWhenUsed=3D"=
194 true" Name=3D"List Number"/>
195 <w:LsdException Locked=3D"false" SemiHidden=3D"true" UnhideWhenUsed=3D"=
196 true" Name=3D"List 2"/>
197 <w:LsdException Locked=3D"false" SemiHidden=3D"true" UnhideWhenUsed=3D"=
198 true" Name=3D"List 3"/>
199 <w:LsdException Locked=3D"false" SemiHidden=3D"true" UnhideWhenUsed=3D"=
200 true" Name=3D"List 4"/>
201 <w:LsdException Locked=3D"false" SemiHidden=3D"true" UnhideWhenUsed=3D"=
202 true" Name=3D"List 5"/>
203 <w:LsdException Locked=3D"false" SemiHidden=3D"true" UnhideWhenUsed=3D"=
204 true" Name=3D"List Bullet 2"/>
205 <w:LsdException Locked=3D"false" SemiHidden=3D"true" UnhideWhenUsed=3D"=
206 true" Name=3D"List Bullet 3"/>
207 <w:LsdException Locked=3D"false" SemiHidden=3D"true" UnhideWhenUsed=3D"=
208 true" Name=3D"List Bullet 4"/>
209 <w:LsdException Locked=3D"false" SemiHidden=3D"true" UnhideWhenUsed=3D"=
210 true" Name=3D"List Bullet 5"/>
211 <w:LsdException Locked=3D"false" SemiHidden=3D"true" UnhideWhenUsed=3D"=
212 true" Name=3D"List Number 2"/>
213 <w:LsdException Locked=3D"false" SemiHidden=3D"true" UnhideWhenUsed=3D"=
214 true" Name=3D"List Number 3"/>
215 <w:LsdException Locked=3D"false" SemiHidden=3D"true" UnhideWhenUsed=3D"=
216 true" Name=3D"List Number 4"/>
217 <w:LsdException Locked=3D"false" SemiHidden=3D"true" UnhideWhenUsed=3D"=
218 true" Name=3D"List Number 5"/>
219 <w:LsdException Locked=3D"false" Priority=3D"10" QFormat=3D"true" Name=3D=
220 "Title"/>
221 <w:LsdException Locked=3D"false" SemiHidden=3D"true" UnhideWhenUsed=3D"=
222 true" Name=3D"Closing"/>
223 <w:LsdException Locked=3D"false" SemiHidden=3D"true" UnhideWhenUsed=3D"=
224 true" Name=3D"Signature"/>
225 <w:LsdException Locked=3D"false" Priority=3D"1" SemiHidden=3D"true" Unh=
226 ideWhenUsed=3D"true" Name=3D"Default Paragraph Font"/>
227 <w:LsdException Locked=3D"false" SemiHidden=3D"true" UnhideWhenUsed=3D"=
228 true" Name=3D"Body Text"/>
229 <w:LsdException Locked=3D"false" SemiHidden=3D"true" UnhideWhenUsed=3D"=
230 true" Name=3D"Body Text Indent"/>
231 <w:LsdException Locked=3D"false" SemiHidden=3D"true" UnhideWhenUsed=3D"=
232 true" Name=3D"List Continue"/>
233 <w:LsdException Locked=3D"false" SemiHidden=3D"true" UnhideWhenUsed=3D"=
234 true" Name=3D"List Continue 2"/>
235 <w:LsdException Locked=3D"false" SemiHidden=3D"true" UnhideWhenUsed=3D"=
236 true" Name=3D"List Continue 3"/>
237 <w:LsdException Locked=3D"false" SemiHidden=3D"true" UnhideWhenUsed=3D"=
238 true" Name=3D"List Continue 4"/>
239 <w:LsdException Locked=3D"false" SemiHidden=3D"true" UnhideWhenUsed=3D"=
240 true" Name=3D"List Continue 5"/>
241 <w:LsdException Locked=3D"false" SemiHidden=3D"true" UnhideWhenUsed=3D"=
242 true" Name=3D"Message Header"/>
243 <w:LsdException Locked=3D"false" Priority=3D"11" QFormat=3D"true" Name=3D=
244 "Subtitle"/>
245 <w:LsdException Locked=3D"false" SemiHidden=3D"true" UnhideWhenUsed=3D"=
246 true" Name=3D"Salutation"/>
247 <w:LsdException Locked=3D"false" SemiHidden=3D"true" UnhideWhenUsed=3D"=
248 true" Name=3D"Date"/>
249 <w:LsdException Locked=3D"false" SemiHidden=3D"true" UnhideWhenUsed=3D"=
250 true" Name=3D"Body Text First Indent"/>
251 <w:LsdException Locked=3D"false" SemiHidden=3D"true" UnhideWhenUsed=3D"=
252 true" Name=3D"Body Text First Indent 2"/>
253 <w:LsdException Locked=3D"false" SemiHidden=3D"true" UnhideWhenUsed=3D"=
254 true" Name=3D"Note Heading"/>
255 <w:LsdException Locked=3D"false" SemiHidden=3D"true" UnhideWhenUsed=3D"=
256 true" Name=3D"Body Text 2"/>
257 <w:LsdException Locked=3D"false" SemiHidden=3D"true" UnhideWhenUsed=3D"=
258 true" Name=3D"Body Text 3"/>
259 <w:LsdException Locked=3D"false" SemiHidden=3D"true" UnhideWhenUsed=3D"=
260 true" Name=3D"Body Text Indent 2"/>
261 <w:LsdException Locked=3D"false" SemiHidden=3D"true" UnhideWhenUsed=3D"=
262 true" Name=3D"Body Text Indent 3"/>
263 <w:LsdException Locked=3D"false" SemiHidden=3D"true" UnhideWhenUsed=3D"=
264 true" Name=3D"Block Text"/>
265 <w:LsdException Locked=3D"false" SemiHidden=3D"true" UnhideWhenUsed=3D"=
266 true" Name=3D"Hyperlink"/>
267 <w:LsdException Locked=3D"false" SemiHidden=3D"true" UnhideWhenUsed=3D"=
268 true" Name=3D"FollowedHyperlink"/>
269 <w:LsdException Locked=3D"false" Priority=3D"22" QFormat=3D"true" Name=3D=
270 "Strong"/>
271 <w:LsdException Locked=3D"false" Priority=3D"20" QFormat=3D"true" Name=3D=
272 "Emphasis"/>
273 <w:LsdException Locked=3D"false" SemiHidden=3D"true" UnhideWhenUsed=3D"=
274 true" Name=3D"Document Map"/>
275 <w:LsdException Locked=3D"false" SemiHidden=3D"true" UnhideWhenUsed=3D"=
276 true" Name=3D"Plain Text"/>
277 <w:LsdException Locked=3D"false" SemiHidden=3D"true" UnhideWhenUsed=3D"=
278 true" Name=3D"E-mail Signature"/>
279 <w:LsdException Locked=3D"false" SemiHidden=3D"true" UnhideWhenUsed=3D"=
280 true" Name=3D"HTML Top of Form"/>
281 <w:LsdException Locked=3D"false" SemiHidden=3D"true" UnhideWhenUsed=3D"=
282 true" Name=3D"HTML Bottom of Form"/>
283 <w:LsdException Locked=3D"false" SemiHidden=3D"true" UnhideWhenUsed=3D"=
284 true" Name=3D"Normal (Web)"/>
285 <w:LsdException Locked=3D"false" SemiHidden=3D"true" UnhideWhenUsed=3D"=
286 true" Name=3D"HTML Acronym"/>
287 <w:LsdException Locked=3D"false" SemiHidden=3D"true" UnhideWhenUsed=3D"=
288 true" Name=3D"HTML Address"/>
289 <w:LsdException Locked=3D"false" SemiHidden=3D"true" UnhideWhenUsed=3D"=
290 true" Name=3D"HTML Cite"/>
291 <w:LsdException Locked=3D"false" SemiHidden=3D"true" UnhideWhenUsed=3D"=
292 true" Name=3D"HTML Code"/>
293 <w:LsdException Locked=3D"false" SemiHidden=3D"true" UnhideWhenUsed=3D"=
294 true" Name=3D"HTML Definition"/>
295 <w:LsdException Locked=3D"false" SemiHidden=3D"true" UnhideWhenUsed=3D"=
296 true" Name=3D"HTML Keyboard"/>
297 <w:LsdException Locked=3D"false" SemiHidden=3D"true" UnhideWhenUsed=3D"=
298 true" Name=3D"HTML Preformatted"/>
299 <w:LsdException Locked=3D"false" SemiHidden=3D"true" UnhideWhenUsed=3D"=
300 true" Name=3D"HTML Sample"/>
301 <w:LsdException Locked=3D"false" SemiHidden=3D"true" UnhideWhenUsed=3D"=
302 true" Name=3D"HTML Typewriter"/>
303 <w:LsdException Locked=3D"false" SemiHidden=3D"true" UnhideWhenUsed=3D"=
304 true" Name=3D"HTML Variable"/>
305 <w:LsdException Locked=3D"false" SemiHidden=3D"true" UnhideWhenUsed=3D"=
306 true" Name=3D"Normal Table"/>
307 <w:LsdException Locked=3D"false" SemiHidden=3D"true" UnhideWhenUsed=3D"=
308 true" Name=3D"annotation subject"/>
309 <w:LsdException Locked=3D"false" SemiHidden=3D"true" UnhideWhenUsed=3D"=
310 true" Name=3D"No List"/>
311 <w:LsdException Locked=3D"false" SemiHidden=3D"true" UnhideWhenUsed=3D"=
312 true" Name=3D"Outline List 1"/>
313 <w:LsdException Locked=3D"false" SemiHidden=3D"true" UnhideWhenUsed=3D"=
314 true" Name=3D"Outline List 2"/>
315 <w:LsdException Locked=3D"false" SemiHidden=3D"true" UnhideWhenUsed=3D"=
316 true" Name=3D"Outline List 3"/>
317 <w:LsdException Locked=3D"false" SemiHidden=3D"true" UnhideWhenUsed=3D"=
318 true" Name=3D"Table Simple 1"/>
319 <w:LsdException Locked=3D"false" SemiHidden=3D"true" UnhideWhenUsed=3D"=
320 true" Name=3D"Table Simple 2"/>
321 <w:LsdException Locked=3D"false" SemiHidden=3D"true" UnhideWhenUsed=3D"=
322 true" Name=3D"Table Simple 3"/>
323 <w:LsdException Locked=3D"false" SemiHidden=3D"true" UnhideWhenUsed=3D"=
324 true" Name=3D"Table Classic 1"/>
325 <w:LsdException Locked=3D"false" SemiHidden=3D"true" UnhideWhenUsed=3D"=
326 true" Name=3D"Table Classic 2"/>
327 <w:LsdException Locked=3D"false" SemiHidden=3D"true" UnhideWhenUsed=3D"=
328 true" Name=3D"Table Classic 3"/>
329 <w:LsdException Locked=3D"false" SemiHidden=3D"true" UnhideWhenUsed=3D"=
330 true" Name=3D"Table Classic 4"/>
331 <w:LsdException Locked=3D"false" SemiHidden=3D"true" UnhideWhenUsed=3D"=
332 true" Name=3D"Table Colorful 1"/>
333 <w:LsdException Locked=3D"false" SemiHidden=3D"true" UnhideWhenUsed=3D"=
334 true" Name=3D"Table Colorful 2"/>
335 <w:LsdException Locked=3D"false" SemiHidden=3D"true" UnhideWhenUsed=3D"=
336 true" Name=3D"Table Colorful 3"/>
337 <w:LsdException Locked=3D"false" SemiHidden=3D"true" UnhideWhenUsed=3D"=
338 true" Name=3D"Table Columns 1"/>
339 <w:LsdException Locked=3D"false" SemiHidden=3D"true" UnhideWhenUsed=3D"=
340 true" Name=3D"Table Columns 2"/>
341 <w:LsdException Locked=3D"false" SemiHidden=3D"true" UnhideWhenUsed=3D"=
342 true" Name=3D"Table Columns 3"/>
343 <w:LsdException Locked=3D"false" SemiHidden=3D"true" UnhideWhenUsed=3D"=
344 true" Name=3D"Table Columns 4"/>
345 <w:LsdException Locked=3D"false" SemiHidden=3D"true" UnhideWhenUsed=3D"=
346 true" Name=3D"Table Columns 5"/>
347 <w:LsdException Locked=3D"false" SemiHidden=3D"true" UnhideWhenUsed=3D"=
348 true" Name=3D"Table Grid 1"/>
349 <w:LsdException Locked=3D"false" SemiHidden=3D"true" UnhideWhenUsed=3D"=
350 true" Name=3D"Table Grid 2"/>
351 <w:LsdException Locked=3D"false" SemiHidden=3D"true" UnhideWhenUsed=3D"=
352 true" Name=3D"Table Grid 3"/>
353 <w:LsdException Locked=3D"false" SemiHidden=3D"true" UnhideWhenUsed=3D"=
354 true" Name=3D"Table Grid 4"/>
355 <w:LsdException Locked=3D"false" SemiHidden=3D"true" UnhideWhenUsed=3D"=
356 true" Name=3D"Table Grid 5"/>
357 <w:LsdException Locked=3D"false" SemiHidden=3D"true" UnhideWhenUsed=3D"=
358 true" Name=3D"Table Grid 6"/>
359 <w:LsdException Locked=3D"false" SemiHidden=3D"true" UnhideWhenUsed=3D"=
360 true" Name=3D"Table Grid 7"/>
361 <w:LsdException Locked=3D"false" SemiHidden=3D"true" UnhideWhenUsed=3D"=
362 true" Name=3D"Table Grid 8"/>
363 <w:LsdException Locked=3D"false" SemiHidden=3D"true" UnhideWhenUsed=3D"=
364 true" Name=3D"Table List 1"/>
365 <w:LsdException Locked=3D"false" SemiHidden=3D"true" UnhideWhenUsed=3D"=
366 true" Name=3D"Table List 2"/>
367 <w:LsdException Locked=3D"false" SemiHidden=3D"true" UnhideWhenUsed=3D"=
368 true" Name=3D"Table List 3"/>
369 <w:LsdException Locked=3D"false" SemiHidden=3D"true" UnhideWhenUsed=3D"=
370 true" Name=3D"Table List 4"/>
371 <w:LsdException Locked=3D"false" SemiHidden=3D"true" UnhideWhenUsed=3D"=
372 true" Name=3D"Table List 5"/>
373 <w:LsdException Locked=3D"false" SemiHidden=3D"true" UnhideWhenUsed=3D"=
374 true" Name=3D"Table List 6"/>
375 <w:LsdException Locked=3D"false" SemiHidden=3D"true" UnhideWhenUsed=3D"=
376 true" Name=3D"Table List 7"/>
377 <w:LsdException Locked=3D"false" SemiHidden=3D"true" UnhideWhenUsed=3D"=
378 true" Name=3D"Table List 8"/>
379 <w:LsdException Locked=3D"false" SemiHidden=3D"true" UnhideWhenUsed=3D"=
380 true" Name=3D"Table 3D effects 1"/>
381 <w:LsdException Locked=3D"false" SemiHidden=3D"true" UnhideWhenUsed=3D"=
382 true" Name=3D"Table 3D effects 2"/>
383 <w:LsdException Locked=3D"false" SemiHidden=3D"true" UnhideWhenUsed=3D"=
384 true" Name=3D"Table 3D effects 3"/>
385 <w:LsdException Locked=3D"false" SemiHidden=3D"true" UnhideWhenUsed=3D"=
386 true" Name=3D"Table Contemporary"/>
387 <w:LsdException Locked=3D"false" SemiHidden=3D"true" UnhideWhenUsed=3D"=
388 true" Name=3D"Table Elegant"/>
389 <w:LsdException Locked=3D"false" SemiHidden=3D"true" UnhideWhenUsed=3D"=
390 true" Name=3D"Table Professional"/>
391 <w:LsdException Locked=3D"false" SemiHidden=3D"true" UnhideWhenUsed=3D"=
392 true" Name=3D"Table Subtle 1"/>
393 <w:LsdException Locked=3D"false" SemiHidden=3D"true" UnhideWhenUsed=3D"=
394 true" Name=3D"Table Subtle 2"/>
395 <w:LsdException Locked=3D"false" SemiHidden=3D"true" UnhideWhenUsed=3D"=
396 true" Name=3D"Table Web 1"/>
397 <w:LsdException Locked=3D"false" SemiHidden=3D"true" UnhideWhenUsed=3D"=
398 true" Name=3D"Table Web 2"/>
399 <w:LsdException Locked=3D"false" SemiHidden=3D"true" UnhideWhenUsed=3D"=
400 true" Name=3D"Table Web 3"/>
401 <w:LsdException Locked=3D"false" SemiHidden=3D"true" UnhideWhenUsed=3D"=
402 true" Name=3D"Balloon Text"/>
403 <w:LsdException Locked=3D"false" Priority=3D"59" Name=3D"Table Grid"/>
404 <w:LsdException Locked=3D"false" SemiHidden=3D"true" UnhideWhenUsed=3D"=
405 true" Name=3D"Table Theme"/>
406 <w:LsdException Locked=3D"false" SemiHidden=3D"true" Name=3D"Placeholde=
407 r Text"/>
408 <w:LsdException Locked=3D"false" Priority=3D"1" QFormat=3D"true" Name=3D=
409 "No Spacing"/>
410 <w:LsdException Locked=3D"false" Priority=3D"60" Name=3D"Light Shading"=
411 />
412 <w:LsdException Locked=3D"false" Priority=3D"61" Name=3D"Light List"/>
413 <w:LsdException Locked=3D"false" Priority=3D"62" Name=3D"Light Grid"/>
414 <w:LsdException Locked=3D"false" Priority=3D"63" Name=3D"Medium Shading=
415 1"/>
416 <w:LsdException Locked=3D"false" Priority=3D"64" Name=3D"Medium Shading=
417 2"/>
418 <w:LsdException Locked=3D"false" Priority=3D"65" Name=3D"Medium List 1"=
419 />
420 <w:LsdException Locked=3D"false" Priority=3D"66" Name=3D"Medium List 2"=
421 />
422 <w:LsdException Locked=3D"false" Priority=3D"67" Name=3D"Medium Grid 1"=
423 />
424 <w:LsdException Locked=3D"false" Priority=3D"68" Name=3D"Medium Grid 2"=
425 />
426 <w:LsdException Locked=3D"false" Priority=3D"69" Name=3D"Medium Grid 3"=
427 />
428 <w:LsdException Locked=3D"false" Priority=3D"70" Name=3D"Dark List"/>
429 <w:LsdException Locked=3D"false" Priority=3D"71" Name=3D"Colorful Shadi=
430 ng"/>
431 <w:LsdException Locked=3D"false" Priority=3D"72" Name=3D"Colorful List"=
432 />
433 <w:LsdException Locked=3D"false" Priority=3D"73" Name=3D"Colorful Grid"=
434 />
435 <w:LsdException Locked=3D"false" Priority=3D"60" Name=3D"Light Shading =
436 Accent 1"/>
437 <w:LsdException Locked=3D"false" Priority=3D"61" Name=3D"Light List Acc=
438 ent 1"/>
439 <w:LsdException Locked=3D"false" Priority=3D"62" Name=3D"Light Grid Acc=
440 ent 1"/>
441 <w:LsdException Locked=3D"false" Priority=3D"63" Name=3D"Medium Shading=
442 1 Accent 1"/>
443 <w:LsdException Locked=3D"false" Priority=3D"64" Name=3D"Medium Shading=
444 2 Accent 1"/>
445 <w:LsdException Locked=3D"false" Priority=3D"65" Name=3D"Medium List 1 =
446 Accent 1"/>
447 <w:LsdException Locked=3D"false" SemiHidden=3D"true" Name=3D"Revision"/=
448 >
449 <w:LsdException Locked=3D"false" Priority=3D"34" QFormat=3D"true" Name=3D=
450 "List Paragraph"/>
451 <w:LsdException Locked=3D"false" Priority=3D"29" QFormat=3D"true" Name=3D=
452 "Quote"/>
453 <w:LsdException Locked=3D"false" Priority=3D"30" QFormat=3D"true" Name=3D=
454 "Intense Quote"/>
455 <w:LsdException Locked=3D"false" Priority=3D"66" Name=3D"Medium List 2 =
456 Accent 1"/>
457 <w:LsdException Locked=3D"false" Priority=3D"67" Name=3D"Medium Grid 1 =
458 Accent 1"/>
459 <w:LsdException Locked=3D"false" Priority=3D"68" Name=3D"Medium Grid 2 =
460 Accent 1"/>
461 <w:LsdException Locked=3D"false" Priority=3D"69" Name=3D"Medium Grid 3 =
462 Accent 1"/>
463 <w:LsdException Locked=3D"false" Priority=3D"70" Name=3D"Dark List Acce=
464 nt 1"/>
465 <w:LsdException Locked=3D"false" Priority=3D"71" Name=3D"Colorful Shadi=
466 ng Accent 1"/>
467 <w:LsdException Locked=3D"false" Priority=3D"72" Name=3D"Colorful List =
468 Accent 1"/>
469 <w:LsdException Locked=3D"false" Priority=3D"73" Name=3D"Colorful Grid =
470 Accent 1"/>
471 <w:LsdException Locked=3D"false" Priority=3D"60" Name=3D"Light Shading =
472 Accent 2"/>
473 <w:LsdException Locked=3D"false" Priority=3D"61" Name=3D"Light List Acc=
474 ent 2"/>
475 <w:LsdException Locked=3D"false" Priority=3D"62" Name=3D"Light Grid Acc=
476 ent 2"/>
477 <w:LsdException Locked=3D"false" Priority=3D"63" Name=3D"Medium Shading=
478 1 Accent 2"/>
479 <w:LsdException Locked=3D"false" Priority=3D"64" Name=3D"Medium Shading=
480 2 Accent 2"/>
481 <w:LsdException Locked=3D"false" Priority=3D"65" Name=3D"Medium List 1 =
482 Accent 2"/>
483 <w:LsdException Locked=3D"false" Priority=3D"66" Name=3D"Medium List 2 =
484 Accent 2"/>
485 <w:LsdException Locked=3D"false" Priority=3D"67" Name=3D"Medium Grid 1 =
486 Accent 2"/>
487 <w:LsdException Locked=3D"false" Priority=3D"68" Name=3D"Medium Grid 2 =
488 Accent 2"/>
489 <w:LsdException Locked=3D"false" Priority=3D"69" Name=3D"Medium Grid 3 =
490 Accent 2"/>
491 <w:LsdException Locked=3D"false" Priority=3D"70" Name=3D"Dark List Acce=
492 nt 2"/>
493 <w:LsdException Locked=3D"false" Priority=3D"71" Name=3D"Colorful Shadi=
494 ng Accent 2"/>
495 <w:LsdException Locked=3D"false" Priority=3D"72" Name=3D"Colorful List =
496 Accent 2"/>
497 <w:LsdException Locked=3D"false" Priority=3D"73" Name=3D"Colorful Grid =
498 Accent 2"/>
499 <w:LsdException Locked=3D"false" Priority=3D"60" Name=3D"Light Shading =
500 Accent 3"/>
501 <w:LsdException Locked=3D"false" Priority=3D"61" Name=3D"Light List Acc=
502 ent 3"/>
503 <w:LsdException Locked=3D"false" Priority=3D"62" Name=3D"Light Grid Acc=
504 ent 3"/>
505 <w:LsdException Locked=3D"false" Priority=3D"63" Name=3D"Medium Shading=
506 1 Accent 3"/>
507 <w:LsdException Locked=3D"false" Priority=3D"64" Name=3D"Medium Shading=
508 2 Accent 3"/>
509 <w:LsdException Locked=3D"false" Priority=3D"65" Name=3D"Medium List 1 =
510 Accent 3"/>
511 <w:LsdException Locked=3D"false" Priority=3D"66" Name=3D"Medium List 2 =
512 Accent 3"/>
513 <w:LsdException Locked=3D"false" Priority=3D"67" Name=3D"Medium Grid 1 =
514 Accent 3"/>
515 <w:LsdException Locked=3D"false" Priority=3D"68" Name=3D"Medium Grid 2 =
516 Accent 3"/>
517 <w:LsdException Locked=3D"false" Priority=3D"69" Name=3D"Medium Grid 3 =
518 Accent 3"/>
519 <w:LsdException Locked=3D"false" Priority=3D"70" Name=3D"Dark List Acce=
520 nt 3"/>
521 <w:LsdException Locked=3D"false" Priority=3D"71" Name=3D"Colorful Shadi=
522 ng Accent 3"/>
523 <w:LsdException Locked=3D"false" Priority=3D"72" Name=3D"Colorful List =
524 Accent 3"/>
525 <w:LsdException Locked=3D"false" Priority=3D"73" Name=3D"Colorful Grid =
526 Accent 3"/>
527 <w:LsdException Locked=3D"false" Priority=3D"60" Name=3D"Light Shading =
528 Accent 4"/>
529 <w:LsdException Locked=3D"false" Priority=3D"61" Name=3D"Light List Acc=
530 ent 4"/>
531 <w:LsdException Locked=3D"false" Priority=3D"62" Name=3D"Light Grid Acc=
532 ent 4"/>
533 <w:LsdException Locked=3D"false" Priority=3D"63" Name=3D"Medium Shading=
534 1 Accent 4"/>
535 <w:LsdException Locked=3D"false" Priority=3D"64" Name=3D"Medium Shading=
536 2 Accent 4"/>
537 <w:LsdException Locked=3D"false" Priority=3D"65" Name=3D"Medium List 1 =
538 Accent 4"/>
539 <w:LsdException Locked=3D"false" Priority=3D"66" Name=3D"Medium List 2 =
540 Accent 4"/>
541 <w:LsdException Locked=3D"false" Priority=3D"67" Name=3D"Medium Grid 1 =
542 Accent 4"/>
543 <w:LsdException Locked=3D"false" Priority=3D"68" Name=3D"Medium Grid 2 =
544 Accent 4"/>
545 <w:LsdException Locked=3D"false" Priority=3D"69" Name=3D"Medium Grid 3 =
546 Accent 4"/>
547 <w:LsdException Locked=3D"false" Priority=3D"70" Name=3D"Dark List Acce=
548 nt 4"/>
549 <w:LsdException Locked=3D"false" Priority=3D"71" Name=3D"Colorful Shadi=
550 ng Accent 4"/>
551 <w:LsdException Locked=3D"false" Priority=3D"72" Name=3D"Colorful List =
552 Accent 4"/>
553 <w:LsdException Locked=3D"false" Priority=3D"73" Name=3D"Colorful Grid =
554 Accent 4"/>
555 <w:LsdException Locked=3D"false" Priority=3D"60" Name=3D"Light Shading =
556 Accent 5"/>
557 <w:LsdException Locked=3D"false" Priority=3D"61" Name=3D"Light List Acc=
558 ent 5"/>
559 <w:LsdException Locked=3D"false" Priority=3D"62" Name=3D"Light Grid Acc=
560 ent 5"/>
561 <w:LsdException Locked=3D"false" Priority=3D"63" Name=3D"Medium Shading=
562 1 Accent 5"/>
563 <w:LsdException Locked=3D"false" Priority=3D"64" Name=3D"Medium Shading=
564 2 Accent 5"/>
565 <w:LsdException Locked=3D"false" Priority=3D"65" Name=3D"Medium List 1 =
566 Accent 5"/>
567 <w:LsdException Locked=3D"false" Priority=3D"66" Name=3D"Medium List 2 =
568 Accent 5"/>
569 <w:LsdException Locked=3D"false" Priority=3D"67" Name=3D"Medium Grid 1 =
570 Accent 5"/>
571 <w:LsdException Locked=3D"false" Priority=3D"68" Name=3D"Medium Grid 2 =
572 Accent 5"/>
573 <w:LsdException Locked=3D"false" Priority=3D"69" Name=3D"Medium Grid 3 =
574 Accent 5"/>
575 <w:LsdException Locked=3D"false" Priority=3D"70" Name=3D"Dark List Acce=
576 nt 5"/>
577 <w:LsdException Locked=3D"false" Priority=3D"71" Name=3D"Colorful Shadi=
578 ng Accent 5"/>
579 <w:LsdException Locked=3D"false" Priority=3D"72" Name=3D"Colorful List =
580 Accent 5"/>
581 <w:LsdException Locked=3D"false" Priority=3D"73" Name=3D"Colorful Grid =
582 Accent 5"/>
583 <w:LsdException Locked=3D"false" Priority=3D"60" Name=3D"Light Shading =
584 Accent 6"/>
585 <w:LsdException Locked=3D"false" Priority=3D"61" Name=3D"Light List Acc=
586 ent 6"/>
587 <w:LsdException Locked=3D"false" Priority=3D"62" Name=3D"Light Grid Acc=
588 ent 6"/>
589 <w:LsdException Locked=3D"false" Priority=3D"63" Name=3D"Medium Shading=
590 1 Accent 6"/>
591 <w:LsdException Locked=3D"false" Priority=3D"64" Name=3D"Medium Shading=
592 2 Accent 6"/>
593 <w:LsdException Locked=3D"false" Priority=3D"65" Name=3D"Medium List 1 =
594 Accent 6"/>
595 <w:LsdException Locked=3D"false" Priority=3D"66" Name=3D"Medium List 2 =
596 Accent 6"/>
597 <w:LsdException Locked=3D"false" Priority=3D"67" Name=3D"Medium Grid 1 =
598 Accent 6"/>
599 <w:LsdException Locked=3D"false" Priority=3D"68" Name=3D"Medium Grid 2 =
600 Accent 6"/>
601 <w:LsdException Locked=3D"false" Priority=3D"69" Name=3D"Medium Grid 3 =
602 Accent 6"/>
603 <w:LsdException Locked=3D"false" Priority=3D"70" Name=3D"Dark List Acce=
604 nt 6"/>
605 <w:LsdException Locked=3D"false" Priority=3D"71" Name=3D"Colorful Shadi=
606 ng Accent 6"/>
607 <w:LsdException Locked=3D"false" Priority=3D"72" Name=3D"Colorful List =
608 Accent 6"/>
609 <w:LsdException Locked=3D"false" Priority=3D"73" Name=3D"Colorful Grid =
610 Accent 6"/>
611 <w:LsdException Locked=3D"false" Priority=3D"19" QFormat=3D"true" Name=3D=
612 "Subtle Emphasis"/>
613 <w:LsdException Locked=3D"false" Priority=3D"21" QFormat=3D"true" Name=3D=
614 "Intense Emphasis"/>
615 <w:LsdException Locked=3D"false" Priority=3D"31" QFormat=3D"true" Name=3D=
616 "Subtle Reference"/>
617 <w:LsdException Locked=3D"false" Priority=3D"32" QFormat=3D"true" Name=3D=
618 "Intense Reference"/>
619 <w:LsdException Locked=3D"false" Priority=3D"33" QFormat=3D"true" Name=3D=
620 "Book Title"/>
621 <w:LsdException Locked=3D"false" Priority=3D"37" SemiHidden=3D"true" Un=
622 hideWhenUsed=3D"true" Name=3D"Bibliography"/>
623 <w:LsdException Locked=3D"false" Priority=3D"39" SemiHidden=3D"true" Un=
624 hideWhenUsed=3D"true" QFormat=3D"true" Name=3D"TOC Heading"/>
625 <w:LsdException Locked=3D"false" Priority=3D"41" Name=3D"Plain Table 1"=
626 />
627 <w:LsdException Locked=3D"false" Priority=3D"42" Name=3D"Plain Table 2"=
628 />
629 <w:LsdException Locked=3D"false" Priority=3D"43" Name=3D"Plain Table 3"=
630 />
631 <w:LsdException Locked=3D"false" Priority=3D"44" Name=3D"Plain Table 4"=
632 />
633 <w:LsdException Locked=3D"false" Priority=3D"45" Name=3D"Plain Table 5"=
634 />
635 <w:LsdException Locked=3D"false" Priority=3D"40" Name=3D"Grid Table Lig=
636 ht"/>
637 <w:LsdException Locked=3D"false" Priority=3D"46" Name=3D"Grid Table 1 L=
638 ight"/>
639 <w:LsdException Locked=3D"false" Priority=3D"47" Name=3D"Grid Table 2"/=
640 >
641 <w:LsdException Locked=3D"false" Priority=3D"48" Name=3D"Grid Table 3"/=
642 >
643 <w:LsdException Locked=3D"false" Priority=3D"49" Name=3D"Grid Table 4"/=
644 >
645 <w:LsdException Locked=3D"false" Priority=3D"50" Name=3D"Grid Table 5 D=
646 ark"/>
647 <w:LsdException Locked=3D"false" Priority=3D"51" Name=3D"Grid Table 6 C=
648 olorful"/>
649 <w:LsdException Locked=3D"false" Priority=3D"52" Name=3D"Grid Table 7 C=
650 olorful"/>
651 <w:LsdException Locked=3D"false" Priority=3D"46" Name=3D"Grid Table 1 L=
652 ight Accent 1"/>
653 <w:LsdException Locked=3D"false" Priority=3D"47" Name=3D"Grid Table 2 A=
654 ccent 1"/>
655 <w:LsdException Locked=3D"false" Priority=3D"48" Name=3D"Grid Table 3 A=
656 ccent 1"/>
657 <w:LsdException Locked=3D"false" Priority=3D"49" Name=3D"Grid Table 4 A=
658 ccent 1"/>
659 <w:LsdException Locked=3D"false" Priority=3D"50" Name=3D"Grid Table 5 D=
660 ark Accent 1"/>
661 <w:LsdException Locked=3D"false" Priority=3D"51" Name=3D"Grid Table 6 C=
662 olorful Accent 1"/>
663 <w:LsdException Locked=3D"false" Priority=3D"52" Name=3D"Grid Table 7 C=
664 olorful Accent 1"/>
665 <w:LsdException Locked=3D"false" Priority=3D"46" Name=3D"Grid Table 1 L=
666 ight Accent 2"/>
667 <w:LsdException Locked=3D"false" Priority=3D"47" Name=3D"Grid Table 2 A=
668 ccent 2"/>
669 <w:LsdException Locked=3D"false" Priority=3D"48" Name=3D"Grid Table 3 A=
670 ccent 2"/>
671 <w:LsdException Locked=3D"false" Priority=3D"49" Name=3D"Grid Table 4 A=
672 ccent 2"/>
673 <w:LsdException Locked=3D"false" Priority=3D"50" Name=3D"Grid Table 5 D=
674 ark Accent 2"/>
675 <w:LsdException Locked=3D"false" Priority=3D"51" Name=3D"Grid Table 6 C=
676 olorful Accent 2"/>
677 <w:LsdException Locked=3D"false" Priority=3D"52" Name=3D"Grid Table 7 C=
678 olorful Accent 2"/>
679 <w:LsdException Locked=3D"false" Priority=3D"46" Name=3D"Grid Table 1 L=
680 ight Accent 3"/>
681 <w:LsdException Locked=3D"false" Priority=3D"47" Name=3D"Grid Table 2 A=
682 ccent 3"/>
683 <w:LsdException Locked=3D"false" Priority=3D"48" Name=3D"Grid Table 3 A=
684 ccent 3"/>
685 <w:LsdException Locked=3D"false" Priority=3D"49" Name=3D"Grid Table 4 A=
686 ccent 3"/>
687 <w:LsdException Locked=3D"false" Priority=3D"50" Name=3D"Grid Table 5 D=
688 ark Accent 3"/>
689 <w:LsdException Locked=3D"false" Priority=3D"51" Name=3D"Grid Table 6 C=
690 olorful Accent 3"/>
691 <w:LsdException Locked=3D"false" Priority=3D"52" Name=3D"Grid Table 7 C=
692 olorful Accent 3"/>
693 <w:LsdException Locked=3D"false" Priority=3D"46" Name=3D"Grid Table 1 L=
694 ight Accent 4"/>
695 <w:LsdException Locked=3D"false" Priority=3D"47" Name=3D"Grid Table 2 A=
696 ccent 4"/>
697 <w:LsdException Locked=3D"false" Priority=3D"48" Name=3D"Grid Table 3 A=
698 ccent 4"/>
699 <w:LsdException Locked=3D"false" Priority=3D"49" Name=3D"Grid Table 4 A=
700 ccent 4"/>
701 <w:LsdException Locked=3D"false" Priority=3D"50" Name=3D"Grid Table 5 D=
702 ark Accent 4"/>
703 <w:LsdException Locked=3D"false" Priority=3D"51" Name=3D"Grid Table 6 C=
704 olorful Accent 4"/>
705 <w:LsdException Locked=3D"false" Priority=3D"52" Name=3D"Grid Table 7 C=
706 olorful Accent 4"/>
707 <w:LsdException Locked=3D"false" Priority=3D"46" Name=3D"Grid Table 1 L=
708 ight Accent 5"/>
709 <w:LsdException Locked=3D"false" Priority=3D"47" Name=3D"Grid Table 2 A=
710 ccent 5"/>
711 <w:LsdException Locked=3D"false" Priority=3D"48" Name=3D"Grid Table 3 A=
712 ccent 5"/>
713 <w:LsdException Locked=3D"false" Priority=3D"49" Name=3D"Grid Table 4 A=
714 ccent 5"/>
715 <w:LsdException Locked=3D"false" Priority=3D"50" Name=3D"Grid Table 5 D=
716 ark Accent 5"/>
717 <w:LsdException Locked=3D"false" Priority=3D"51" Name=3D"Grid Table 6 C=
718 olorful Accent 5"/>
719 <w:LsdException Locked=3D"false" Priority=3D"52" Name=3D"Grid Table 7 C=
720 olorful Accent 5"/>
721 <w:LsdException Locked=3D"false" Priority=3D"46" Name=3D"Grid Table 1 L=
722 ight Accent 6"/>
723 <w:LsdException Locked=3D"false" Priority=3D"47" Name=3D"Grid Table 2 A=
724 ccent 6"/>
725 <w:LsdException Locked=3D"false" Priority=3D"48" Name=3D"Grid Table 3 A=
726 ccent 6"/>
727 <w:LsdException Locked=3D"false" Priority=3D"49" Name=3D"Grid Table 4 A=
728 ccent 6"/>
729 <w:LsdException Locked=3D"false" Priority=3D"50" Name=3D"Grid Table 5 D=
730 ark Accent 6"/>
731 <w:LsdException Locked=3D"false" Priority=3D"51" Name=3D"Grid Table 6 C=
732 olorful Accent 6"/>
733 <w:LsdException Locked=3D"false" Priority=3D"52" Name=3D"Grid Table 7 C=
734 olorful Accent 6"/>
735 <w:LsdException Locked=3D"false" Priority=3D"46" Name=3D"List Table 1 L=
736 ight"/>
737 <w:LsdException Locked=3D"false" Priority=3D"47" Name=3D"List Table 2"/=
738 >
739 <w:LsdException Locked=3D"false" Priority=3D"48" Name=3D"List Table 3"/=
740 >
741 <w:LsdException Locked=3D"false" Priority=3D"49" Name=3D"List Table 4"/=
742 >
743 <w:LsdException Locked=3D"false" Priority=3D"50" Name=3D"List Table 5 D=
744 ark"/>
745 <w:LsdException Locked=3D"false" Priority=3D"51" Name=3D"List Table 6 C=
746 olorful"/>
747 <w:LsdException Locked=3D"false" Priority=3D"52" Name=3D"List Table 7 C=
748 olorful"/>
749 <w:LsdException Locked=3D"false" Priority=3D"46" Name=3D"List Table 1 L=
750 ight Accent 1"/>
751 <w:LsdException Locked=3D"false" Priority=3D"47" Name=3D"List Table 2 A=
752 ccent 1"/>
753 <w:LsdException Locked=3D"false" Priority=3D"48" Name=3D"List Table 3 A=
754 ccent 1"/>
755 <w:LsdException Locked=3D"false" Priority=3D"49" Name=3D"List Table 4 A=
756 ccent 1"/>
757 <w:LsdException Locked=3D"false" Priority=3D"50" Name=3D"List Table 5 D=
758 ark Accent 1"/>
759 <w:LsdException Locked=3D"false" Priority=3D"51" Name=3D"List Table 6 C=
760 olorful Accent 1"/>
761 <w:LsdException Locked=3D"false" Priority=3D"52" Name=3D"List Table 7 C=
762 olorful Accent 1"/>
763 <w:LsdException Locked=3D"false" Priority=3D"46" Name=3D"List Table 1 L=
764 ight Accent 2"/>
765 <w:LsdException Locked=3D"false" Priority=3D"47" Name=3D"List Table 2 A=
766 ccent 2"/>
767 <w:LsdException Locked=3D"false" Priority=3D"48" Name=3D"List Table 3 A=
768 ccent 2"/>
769 <w:LsdException Locked=3D"false" Priority=3D"49" Name=3D"List Table 4 A=
770 ccent 2"/>
771 <w:LsdException Locked=3D"false" Priority=3D"50" Name=3D"List Table 5 D=
772 ark Accent 2"/>
773 <w:LsdException Locked=3D"false" Priority=3D"51" Name=3D"List Table 6 C=
774 olorful Accent 2"/>
775 <w:LsdException Locked=3D"false" Priority=3D"52" Name=3D"List Table 7 C=
776 olorful Accent 2"/>
777 <w:LsdException Locked=3D"false" Priority=3D"46" Name=3D"List Table 1 L=
778 ight Accent 3"/>
779 <w:LsdException Locked=3D"false" Priority=3D"47" Name=3D"List Table 2 A=
780 ccent 3"/>
781 <w:LsdException Locked=3D"false" Priority=3D"48" Name=3D"List Table 3 A=
782 ccent 3"/>
783 <w:LsdException Locked=3D"false" Priority=3D"49" Name=3D"List Table 4 A=
784 ccent 3"/>
785 <w:LsdException Locked=3D"false" Priority=3D"50" Name=3D"List Table 5 D=
786 ark Accent 3"/>
787 <w:LsdException Locked=3D"false" Priority=3D"51" Name=3D"List Table 6 C=
788 olorful Accent 3"/>
789 <w:LsdException Locked=3D"false" Priority=3D"52" Name=3D"List Table 7 C=
790 olorful Accent 3"/>
791 <w:LsdException Locked=3D"false" Priority=3D"46" Name=3D"List Table 1 L=
792 ight Accent 4"/>
793 <w:LsdException Locked=3D"false" Priority=3D"47" Name=3D"List Table 2 A=
794 ccent 4"/>
795 <w:LsdException Locked=3D"false" Priority=3D"48" Name=3D"List Table 3 A=
796 ccent 4"/>
797 <w:LsdException Locked=3D"false" Priority=3D"49" Name=3D"List Table 4 A=
798 ccent 4"/>
799 <w:LsdException Locked=3D"false" Priority=3D"50" Name=3D"List Table 5 D=
800 ark Accent 4"/>
801 <w:LsdException Locked=3D"false" Priority=3D"51" Name=3D"List Table 6 C=
802 olorful Accent 4"/>
803 <w:LsdException Locked=3D"false" Priority=3D"52" Name=3D"List Table 7 C=
804 olorful Accent 4"/>
805 <w:LsdException Locked=3D"false" Priority=3D"46" Name=3D"List Table 1 L=
806 ight Accent 5"/>
807 <w:LsdException Locked=3D"false" Priority=3D"47" Name=3D"List Table 2 A=
808 ccent 5"/>
809 <w:LsdException Locked=3D"false" Priority=3D"48" Name=3D"List Table 3 A=
810 ccent 5"/>
811 <w:LsdException Locked=3D"false" Priority=3D"49" Name=3D"List Table 4 A=
812 ccent 5"/>
813 <w:LsdException Locked=3D"false" Priority=3D"50" Name=3D"List Table 5 D=
814 ark Accent 5"/>
815 <w:LsdException Locked=3D"false" Priority=3D"51" Name=3D"List Table 6 C=
816 olorful Accent 5"/>
817 <w:LsdException Locked=3D"false" Priority=3D"52" Name=3D"List Table 7 C=
818 olorful Accent 5"/>
819 <w:LsdException Locked=3D"false" Priority=3D"46" Name=3D"List Table 1 L=
820 ight Accent 6"/>
821 <w:LsdException Locked=3D"false" Priority=3D"47" Name=3D"List Table 2 A=
822 ccent 6"/>
823 <w:LsdException Locked=3D"false" Priority=3D"48" Name=3D"List Table 3 A=
824 ccent 6"/>
825 <w:LsdException Locked=3D"false" Priority=3D"49" Name=3D"List Table 4 A=
826 ccent 6"/>
827 <w:LsdException Locked=3D"false" Priority=3D"50" Name=3D"List Table 5 D=
828 ark Accent 6"/>
829 <w:LsdException Locked=3D"false" Priority=3D"51" Name=3D"List Table 6 C=
830 olorful Accent 6"/>
831 <w:LsdException Locked=3D"false" Priority=3D"52" Name=3D"List Table 7 C=
832 olorful Accent 6"/>
833 </w:LatentStyles>
834 </xml><![endif]--><style><!--
835 /* Font Definitions */
836 @font-face
837 {font-family:"Cambria Math";
838 panose-1:2 4 5 3 5 4 6 3 2 4;
839 mso-font-charset:0;
840 mso-generic-font-family:roman;
841 mso-font-pitch:variable;
842 mso-font-signature:-536870145 1107305727 0 0 415 0;}
843 @font-face
844 {font-family:Calibri;
845 panose-1:2 15 5 2 2 2 4 3 2 4;
846 mso-font-alt:"Times New Roman";
847 mso-font-charset:0;
848 mso-generic-font-family:swiss;
849 mso-font-pitch:variable;
850 mso-font-signature:-536870145 1073786111 1 0 415 0;}
851 /* Style Definitions */
852 p.MsoNormal, li.MsoNormal, div.MsoNormal
853 {mso-style-unhide:no;
854 mso-style-qformat:yes;
855 mso-style-parent:"";
856 margin:0in;
857 margin-bottom:.0001pt;
858 mso-pagination:widow-orphan;
859 font-size:11.0pt;
860 font-family:"Calibri",sans-serif;
861 mso-ascii-font-family:Calibri;
862 mso-ascii-theme-font:minor-latin;
863 mso-fareast-font-family:Calibri;
864 mso-fareast-theme-font:minor-latin;
865 mso-hansi-font-family:Calibri;
866 mso-hansi-theme-font:minor-latin;
867 mso-bidi-font-family:"Times New Roman";
868 mso-bidi-theme-font:minor-bidi;}
869 a:link, span.MsoHyperlink
870 {mso-style-noshow:yes;
871 mso-style-priority:99;
872 color:blue;
873 mso-themecolor:hyperlink;
874 text-decoration:underline;
875 text-underline:single;}
876 a:visited, span.MsoHyperlinkFollowed
877 {mso-style-noshow:yes;
878 mso-style-priority:99;
879 color:purple;
880 mso-themecolor:followedhyperlink;
881 text-decoration:underline;
882 text-underline:single;}
883 p.MsoPlainText, li.MsoPlainText, div.MsoPlainText
884 {mso-style-noshow:yes;
885 mso-style-priority:99;
886 mso-style-link:"Plain Text Char";
887 margin:0in;
888 margin-bottom:.0001pt;
889 mso-pagination:widow-orphan;
890 font-size:11.0pt;
891 mso-bidi-font-size:10.5pt;
892 font-family:"Calibri",sans-serif;
893 mso-fareast-font-family:Calibri;
894 mso-fareast-theme-font:minor-latin;
895 mso-bidi-font-family:"Times New Roman";
896 mso-bidi-theme-font:minor-bidi;}
897 span.PlainTextChar
898 {mso-style-name:"Plain Text Char";
899 mso-style-noshow:yes;
900 mso-style-priority:99;
901 mso-style-unhide:no;
902 mso-style-locked:yes;
903 mso-style-link:"Plain Text";
904 mso-bidi-font-size:10.5pt;
905 font-family:"Calibri",sans-serif;
906 mso-ascii-font-family:Calibri;
907 mso-hansi-font-family:Calibri;}
908 span.EmailStyle19
909 {mso-style-type:personal-compose;
910 mso-style-noshow:yes;
911 mso-style-unhide:no;}
912 .MsoChpDefault
913 {mso-style-type:export-only;
914 mso-default-props:yes;
915 font-size:10.0pt;
916 mso-ansi-font-size:10.0pt;
917 mso-bidi-font-size:10.0pt;
918 font-family:"Calibri",sans-serif;
919 mso-ascii-font-family:Calibri;
920 mso-ascii-theme-font:minor-latin;
921 mso-fareast-font-family:Calibri;
922 mso-fareast-theme-font:minor-latin;
923 mso-hansi-font-family:Calibri;
924 mso-hansi-theme-font:minor-latin;
925 mso-bidi-font-family:"Times New Roman";
926 mso-bidi-theme-font:minor-bidi;}
927 @page WordSection1
928 {size:8.5in 11.0in;
929 margin:1.0in 1.0in 1.0in 1.0in;
930 mso-header-margin:.5in;
931 mso-footer-margin:.5in;
932 mso-paper-source:0;}
933 div.WordSection1
934 {page:WordSection1;}
935 --></style><!--[if gte mso 10]><style>/* Style Definitions */
936 table.MsoNormalTable
937 {mso-style-name:"Table Normal";
938 mso-tstyle-rowband-size:0;
939 mso-tstyle-colband-size:0;
940 mso-style-noshow:yes;
941 mso-style-priority:99;
942 mso-style-parent:"";
943 mso-padding-alt:0in 5.4pt 0in 5.4pt;
944 mso-para-margin:0in;
945 mso-para-margin-bottom:.0001pt;
946 mso-pagination:widow-orphan;
947 font-size:10.0pt;
948 font-family:"Calibri",sans-serif;
949 mso-ascii-font-family:Calibri;
950 mso-ascii-theme-font:minor-latin;
951 mso-hansi-font-family:Calibri;
952 mso-hansi-theme-font:minor-latin;}
953 </style><![endif]--><!--[if gte mso 9]><xml>
954 <o:shapedefaults v:ext=3D"edit" spidmax=3D"1026" />
955 </xml><![endif]--><!--[if gte mso 9]><xml>
956 <o:shapelayout v:ext=3D"edit">
957 <o:idmap v:ext=3D"edit" data=3D"1" />
958 </o:shapelayout></xml><![endif]--></head><body lang=3DEN-US link=3Dblue=
959 vlink=3Dpurple style=3D'tab-interval:.5in'><div class=3DWordSection1><=
960 p class=3DMsoPlainText>Simple, unadorned test email generated by Outloo=
961 k 2010. It is in HTML format, but no special formatting has been chosen=
962 . I=E2=80=99m going to save this as a draft and then manually drop it i=
963 nto the Inbox for scraping by Redmine 3.0.2.<o:p></o:p></p></div></body=
964 ></html>
965
966 --Mark=_539924359269962179476--
@@ -0,0 +1,65
1 From: "John Smith" <jsmith@somenet.foo>
2 To: redmine <r@MYCOMPANYNAME.com>
3 Subject: Upgrade Redmine to 3.0.x
4 Thread-Topic: Upgrade Redmine to 3.0.x
5 Thread-Index: AQHQknBe94y5Or7Yl02JransMRF41p2Dv6Hu
6 Date: Tue, 19 May 2015 16:27:43 -0400
7 Message-ID: <A2D341A8808F024CAFA63F1287B9929CF1BC440F@EMBX01.exch.local>
8 Accept-Language: en-US
9 Content-Language: en-US
10 X-MS-Exchange-Organization-AuthAs: Internal
11 X-MS-Exchange-Organization-AuthMechanism: 04
12 X-MS-Exchange-Organization-AuthSource: EHUB01.exch.local
13 X-MS-Has-Attach:
14 X-MS-Exchange-Organization-SCL: -1
15 X-MS-TNEF-Correlator:
16 Content-Type: text/html; charset="iso-8859-1"
17 Content-Transfer-Encoding: quoted-printable
18 MIME-Version: 1.0
19
20 <html dir=3D"ltr">
21 <head>
22 <meta http-equiv=3D"Content-Type" content=3D"text/html; charset=3Diso-8859-=
23 1">
24 <style>=0A=
25 <!--=0A=
26 body=0A=
27 {font-family:Verdana,sans-serif;=0A=
28 font-size:0.8em;=0A=
29 color:#484848}=0A=
30 h1, h2, h3=0A=
31 {font-family:"Trebuchet MS",Verdana,sans-serif;=0A=
32 margin:0px}=0A=
33 h1=0A=
34 {font-size:1.2em}=0A=
35 h2, h3=0A=
36 {font-size:1.1em}=0A=
37 a, a:link, a:visited=0A=
38 {color:#2A5685}=0A=
39 a:hover, a:active=0A=
40 {color:#c61a1a}=0A=
41 fieldset.attachments=0A=
42 {border-width:1px 0 0 0}=0A=
43 hr=0A=
44 {width:100%;=0A=
45 height:1px;=0A=
46 background:#ccc;=0A=
47 border:0}=0A=
48 span.footer=0A=
49 {font-size:0.8em;=0A=
50 font-style:italic}=0A=
51 -->=0A=
52 </style><style id=3D"owaParaStyle" type=3D"text/css">P {margin-top:0;margin=
53 -bottom:0;}</style>
54 </head>
55 <body ocsi=3D"0" fpstyle=3D"1">
56 <div style=3D"direction: ltr;font-family: Tahoma;color: #000000;font-size: =
57 10pt;">A mess.<br>
58 <div><br>
59 <div style=3D"font-family:Tahoma; font-size:13px">--Geoff Maciolek<br>
60 MYCOMPANYNAME, LLC<br>
61 </div>
62 </div>
63 </div>
64 </body>
65 </html> No newline at end of file
@@ -0,0 +1,30
1 # Redmine - project management software
2 # Copyright (C) 2006-2015 Jean-Philippe Lang
3 #
4 # This program is free software; you can redistribute it and/or
5 # modify it under the terms of the GNU General Public License
6 # as published by the Free Software Foundation; either version 2
7 # of the License, or (at your option) any later version.
8 #
9 # This program is distributed in the hope that it will be useful,
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 # GNU General Public License for more details.
13 #
14 # You should have received a copy of the GNU General Public License
15 # along with this program; if not, write to the Free Software
16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
17
18 require File.expand_path('../../../../../test_helper', __FILE__)
19
20 class Redmine::WikiFormatting::HtmlParserTest < ActiveSupport::TestCase
21
22 def setup
23 @parser = Redmine::WikiFormatting::HtmlParser
24 end
25
26 def test_convert_line_breaks
27 assert_equal "A html snippet with\na new line.",
28 @parser.to_text('<p>A html snippet with<br>a new line.</p>')
29 end
30 end
@@ -0,0 +1,30
1 # Redmine - project management software
2 # Copyright (C) 2006-2015 Jean-Philippe Lang
3 #
4 # This program is free software; you can redistribute it and/or
5 # modify it under the terms of the GNU General Public License
6 # as published by the Free Software Foundation; either version 2
7 # of the License, or (at your option) any later version.
8 #
9 # This program is distributed in the hope that it will be useful,
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 # GNU General Public License for more details.
13 #
14 # You should have received a copy of the GNU General Public License
15 # along with this program; if not, write to the Free Software
16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
17
18 require File.expand_path('../../../../../test_helper', __FILE__)
19
20 class Redmine::WikiFormatting::MarkdownHtmlParserTest < ActiveSupport::TestCase
21
22 def setup
23 @parser = Redmine::WikiFormatting::Markdown::HtmlParser
24 end
25
26 def test_should_convert_tags
27 assert_equal 'A **simple** html snippet.',
28 @parser.to_text('<p>A <b>simple</b> html snippet.</p>')
29 end
30 end
@@ -0,0 +1,30
1 # Redmine - project management software
2 # Copyright (C) 2006-2015 Jean-Philippe Lang
3 #
4 # This program is free software; you can redistribute it and/or
5 # modify it under the terms of the GNU General Public License
6 # as published by the Free Software Foundation; either version 2
7 # of the License, or (at your option) any later version.
8 #
9 # This program is distributed in the hope that it will be useful,
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 # GNU General Public License for more details.
13 #
14 # You should have received a copy of the GNU General Public License
15 # along with this program; if not, write to the Free Software
16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
17
18 require File.expand_path('../../../../../test_helper', __FILE__)
19
20 class Redmine::WikiFormatting::TextileHtmlParserTest < ActiveSupport::TestCase
21
22 def setup
23 @parser = Redmine::WikiFormatting::Textile::HtmlParser
24 end
25
26 def test_should_convert_tags
27 assert_equal 'A *simple* html snippet.',
28 @parser.to_text('<p>A <b>simple</b> html snippet.</p>')
29 end
30 end
@@ -1,110 +1,111
1 source 'https://rubygems.org'
1 source 'https://rubygems.org'
2
2
3 if Gem::Version.new(Bundler::VERSION) < Gem::Version.new('1.5.0')
3 if Gem::Version.new(Bundler::VERSION) < Gem::Version.new('1.5.0')
4 abort "Redmine requires Bundler 1.5.0 or higher (you're using #{Bundler::VERSION}).\nPlease update with 'gem update bundler'."
4 abort "Redmine requires Bundler 1.5.0 or higher (you're using #{Bundler::VERSION}).\nPlease update with 'gem update bundler'."
5 end
5 end
6
6
7 gem "rails", "4.2.1"
7 gem "rails", "4.2.1"
8 gem "jquery-rails", "~> 3.1.1"
8 gem "jquery-rails", "~> 3.1.1"
9 gem "coderay", "~> 1.1.0"
9 gem "coderay", "~> 1.1.0"
10 gem "builder", ">= 3.0.4"
10 gem "builder", ">= 3.0.4"
11 gem "request_store", "1.0.5"
11 gem "request_store", "1.0.5"
12 gem "mime-types"
12 gem "mime-types"
13 gem "protected_attributes"
13 gem "protected_attributes"
14 gem "actionpack-action_caching"
14 gem "actionpack-action_caching"
15 gem "actionpack-xml_parser"
15 gem "actionpack-xml_parser"
16 gem "loofah", "~> 2.0"
16
17
17 # Windows does not include zoneinfo files, so bundle the tzinfo-data gem
18 # Windows does not include zoneinfo files, so bundle the tzinfo-data gem
18 gem 'tzinfo-data', platforms: [:mingw, :x64_mingw, :mswin, :jruby]
19 gem 'tzinfo-data', platforms: [:mingw, :x64_mingw, :mswin, :jruby]
19 gem "rbpdf", "~> 1.18.5"
20 gem "rbpdf", "~> 1.18.5"
20
21
21 # Optional gem for LDAP authentication
22 # Optional gem for LDAP authentication
22 group :ldap do
23 group :ldap do
23 gem "net-ldap", "~> 0.3.1"
24 gem "net-ldap", "~> 0.3.1"
24 end
25 end
25
26
26 # Optional gem for OpenID authentication
27 # Optional gem for OpenID authentication
27 group :openid do
28 group :openid do
28 gem "ruby-openid", "~> 2.3.0", :require => "openid"
29 gem "ruby-openid", "~> 2.3.0", :require => "openid"
29 gem "rack-openid"
30 gem "rack-openid"
30 end
31 end
31
32
32 platforms :mri, :mingw, :x64_mingw do
33 platforms :mri, :mingw, :x64_mingw do
33 # Optional gem for exporting the gantt to a PNG file, not supported with jruby
34 # Optional gem for exporting the gantt to a PNG file, not supported with jruby
34 group :rmagick do
35 group :rmagick do
35 gem "rmagick", ">= 2.14.0"
36 gem "rmagick", ">= 2.14.0"
36 end
37 end
37
38
38 # Optional Markdown support, not for JRuby
39 # Optional Markdown support, not for JRuby
39 group :markdown do
40 group :markdown do
40 gem "redcarpet", "~> 3.1.2"
41 gem "redcarpet", "~> 3.1.2"
41 end
42 end
42 end
43 end
43
44
44 platforms :jruby do
45 platforms :jruby do
45 # jruby-openssl is bundled with JRuby 1.7.0
46 # jruby-openssl is bundled with JRuby 1.7.0
46 gem "jruby-openssl" if Object.const_defined?(:JRUBY_VERSION) && JRUBY_VERSION < '1.7.0'
47 gem "jruby-openssl" if Object.const_defined?(:JRUBY_VERSION) && JRUBY_VERSION < '1.7.0'
47 gem "activerecord-jdbc-adapter", "~> 1.3.2"
48 gem "activerecord-jdbc-adapter", "~> 1.3.2"
48 end
49 end
49
50
50 # Include database gems for the adapters found in the database
51 # Include database gems for the adapters found in the database
51 # configuration file
52 # configuration file
52 require 'erb'
53 require 'erb'
53 require 'yaml'
54 require 'yaml'
54 database_file = File.join(File.dirname(__FILE__), "config/database.yml")
55 database_file = File.join(File.dirname(__FILE__), "config/database.yml")
55 if File.exist?(database_file)
56 if File.exist?(database_file)
56 database_config = YAML::load(ERB.new(IO.read(database_file)).result)
57 database_config = YAML::load(ERB.new(IO.read(database_file)).result)
57 adapters = database_config.values.map {|c| c['adapter']}.compact.uniq
58 adapters = database_config.values.map {|c| c['adapter']}.compact.uniq
58 if adapters.any?
59 if adapters.any?
59 adapters.each do |adapter|
60 adapters.each do |adapter|
60 case adapter
61 case adapter
61 when 'mysql2'
62 when 'mysql2'
62 gem "mysql2", "~> 0.3.11", :platforms => [:mri, :mingw, :x64_mingw]
63 gem "mysql2", "~> 0.3.11", :platforms => [:mri, :mingw, :x64_mingw]
63 gem "activerecord-jdbcmysql-adapter", :platforms => :jruby
64 gem "activerecord-jdbcmysql-adapter", :platforms => :jruby
64 when 'mysql'
65 when 'mysql'
65 gem "activerecord-jdbcmysql-adapter", :platforms => :jruby
66 gem "activerecord-jdbcmysql-adapter", :platforms => :jruby
66 when /postgresql/
67 when /postgresql/
67 gem "pg", "~> 0.18.1", :platforms => [:mri, :mingw, :x64_mingw]
68 gem "pg", "~> 0.18.1", :platforms => [:mri, :mingw, :x64_mingw]
68 gem "activerecord-jdbcpostgresql-adapter", :platforms => :jruby
69 gem "activerecord-jdbcpostgresql-adapter", :platforms => :jruby
69 when /sqlite3/
70 when /sqlite3/
70 gem "sqlite3", :platforms => [:mri, :mingw, :x64_mingw]
71 gem "sqlite3", :platforms => [:mri, :mingw, :x64_mingw]
71 gem "jdbc-sqlite3", "< 3.8", :platforms => :jruby
72 gem "jdbc-sqlite3", "< 3.8", :platforms => :jruby
72 gem "activerecord-jdbcsqlite3-adapter", :platforms => :jruby
73 gem "activerecord-jdbcsqlite3-adapter", :platforms => :jruby
73 when /sqlserver/
74 when /sqlserver/
74 gem "tiny_tds", "~> 0.6.2", :platforms => [:mri, :mingw, :x64_mingw]
75 gem "tiny_tds", "~> 0.6.2", :platforms => [:mri, :mingw, :x64_mingw]
75 gem "activerecord-sqlserver-adapter", :platforms => [:mri, :mingw, :x64_mingw]
76 gem "activerecord-sqlserver-adapter", :platforms => [:mri, :mingw, :x64_mingw]
76 else
77 else
77 warn("Unknown database adapter `#{adapter}` found in config/database.yml, use Gemfile.local to load your own database gems")
78 warn("Unknown database adapter `#{adapter}` found in config/database.yml, use Gemfile.local to load your own database gems")
78 end
79 end
79 end
80 end
80 else
81 else
81 warn("No adapter found in config/database.yml, please configure it first")
82 warn("No adapter found in config/database.yml, please configure it first")
82 end
83 end
83 else
84 else
84 warn("Please configure your config/database.yml first")
85 warn("Please configure your config/database.yml first")
85 end
86 end
86
87
87 group :development do
88 group :development do
88 gem "rdoc", ">= 2.4.2"
89 gem "rdoc", ">= 2.4.2"
89 gem "yard"
90 gem "yard"
90 end
91 end
91
92
92 group :test do
93 group :test do
93 gem "minitest"
94 gem "minitest"
94 gem "rails-dom-testing"
95 gem "rails-dom-testing"
95 gem "mocha"
96 gem "mocha"
96 gem "simplecov", "~> 0.9.1", :require => false
97 gem "simplecov", "~> 0.9.1", :require => false
97 # For running UI tests
98 # For running UI tests
98 gem "capybara"
99 gem "capybara"
99 gem "selenium-webdriver"
100 gem "selenium-webdriver"
100 end
101 end
101
102
102 local_gemfile = File.join(File.dirname(__FILE__), "Gemfile.local")
103 local_gemfile = File.join(File.dirname(__FILE__), "Gemfile.local")
103 if File.exists?(local_gemfile)
104 if File.exists?(local_gemfile)
104 eval_gemfile local_gemfile
105 eval_gemfile local_gemfile
105 end
106 end
106
107
107 # Load plugins' Gemfiles
108 # Load plugins' Gemfiles
108 Dir.glob File.expand_path("../plugins/*/{Gemfile,PluginGemfile}", __FILE__) do |file|
109 Dir.glob File.expand_path("../plugins/*/{Gemfile,PluginGemfile}", __FILE__) do |file|
109 eval_gemfile file
110 eval_gemfile file
110 end
111 end
@@ -1,556 +1,558
1 # Redmine - project management software
1 # Redmine - project management software
2 # Copyright (C) 2006-2015 Jean-Philippe Lang
2 # Copyright (C) 2006-2015 Jean-Philippe Lang
3 #
3 #
4 # This program is free software; you can redistribute it and/or
4 # This program is free software; you can redistribute it and/or
5 # modify it under the terms of the GNU General Public License
5 # modify it under the terms of the GNU General Public License
6 # as published by the Free Software Foundation; either version 2
6 # as published by the Free Software Foundation; either version 2
7 # of the License, or (at your option) any later version.
7 # of the License, or (at your option) any later version.
8 #
8 #
9 # This program is distributed in the hope that it will be useful,
9 # This program is distributed in the hope that it will be useful,
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 # GNU General Public License for more details.
12 # GNU General Public License for more details.
13 #
13 #
14 # You should have received a copy of the GNU General Public License
14 # You should have received a copy of the GNU General Public License
15 # along with this program; if not, write to the Free Software
15 # along with this program; if not, write to the Free Software
16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
17
17
18 class MailHandler < ActionMailer::Base
18 class MailHandler < ActionMailer::Base
19 include ActionView::Helpers::SanitizeHelper
19 include ActionView::Helpers::SanitizeHelper
20 include Redmine::I18n
20 include Redmine::I18n
21
21
22 class UnauthorizedAction < StandardError; end
22 class UnauthorizedAction < StandardError; end
23 class MissingInformation < StandardError; end
23 class MissingInformation < StandardError; end
24
24
25 attr_reader :email, :user, :handler_options
25 attr_reader :email, :user, :handler_options
26
26
27 def self.receive(raw_mail, options={})
27 def self.receive(raw_mail, options={})
28 options = options.deep_dup
28 options = options.deep_dup
29
29
30 options[:issue] ||= {}
30 options[:issue] ||= {}
31
31
32 if options[:allow_override].is_a?(String)
32 if options[:allow_override].is_a?(String)
33 options[:allow_override] = options[:allow_override].split(',').collect(&:strip)
33 options[:allow_override] = options[:allow_override].split(',').collect(&:strip)
34 end
34 end
35 options[:allow_override] ||= []
35 options[:allow_override] ||= []
36 # Project needs to be overridable if not specified
36 # Project needs to be overridable if not specified
37 options[:allow_override] << 'project' unless options[:issue].has_key?(:project)
37 options[:allow_override] << 'project' unless options[:issue].has_key?(:project)
38 # Status overridable by default
38 # Status overridable by default
39 options[:allow_override] << 'status' unless options[:issue].has_key?(:status)
39 options[:allow_override] << 'status' unless options[:issue].has_key?(:status)
40
40
41 options[:no_account_notice] = (options[:no_account_notice].to_s == '1')
41 options[:no_account_notice] = (options[:no_account_notice].to_s == '1')
42 options[:no_notification] = (options[:no_notification].to_s == '1')
42 options[:no_notification] = (options[:no_notification].to_s == '1')
43 options[:no_permission_check] = (options[:no_permission_check].to_s == '1')
43 options[:no_permission_check] = (options[:no_permission_check].to_s == '1')
44
44
45 raw_mail.force_encoding('ASCII-8BIT')
45 raw_mail.force_encoding('ASCII-8BIT')
46
46
47 ActiveSupport::Notifications.instrument("receive.action_mailer") do |payload|
47 ActiveSupport::Notifications.instrument("receive.action_mailer") do |payload|
48 mail = Mail.new(raw_mail)
48 mail = Mail.new(raw_mail)
49 set_payload_for_mail(payload, mail)
49 set_payload_for_mail(payload, mail)
50 new.receive(mail, options)
50 new.receive(mail, options)
51 end
51 end
52 end
52 end
53
53
54 # Receives an email and rescues any exception
54 # Receives an email and rescues any exception
55 def self.safe_receive(*args)
55 def self.safe_receive(*args)
56 receive(*args)
56 receive(*args)
57 rescue Exception => e
57 rescue Exception => e
58 logger.error "An unexpected error occurred when receiving email: #{e.message}" if logger
58 logger.error "An unexpected error occurred when receiving email: #{e.message}" if logger
59 return false
59 return false
60 end
60 end
61
61
62 # Extracts MailHandler options from environment variables
62 # Extracts MailHandler options from environment variables
63 # Use when receiving emails with rake tasks
63 # Use when receiving emails with rake tasks
64 def self.extract_options_from_env(env)
64 def self.extract_options_from_env(env)
65 options = {:issue => {}}
65 options = {:issue => {}}
66 %w(project status tracker category priority).each do |option|
66 %w(project status tracker category priority).each do |option|
67 options[:issue][option.to_sym] = env[option] if env[option]
67 options[:issue][option.to_sym] = env[option] if env[option]
68 end
68 end
69 %w(allow_override unknown_user no_permission_check no_account_notice default_group).each do |option|
69 %w(allow_override unknown_user no_permission_check no_account_notice default_group).each do |option|
70 options[option.to_sym] = env[option] if env[option]
70 options[option.to_sym] = env[option] if env[option]
71 end
71 end
72 if env['private']
72 if env['private']
73 options[:issue][:is_private] = '1'
73 options[:issue][:is_private] = '1'
74 end
74 end
75 options
75 options
76 end
76 end
77
77
78 def logger
78 def logger
79 Rails.logger
79 Rails.logger
80 end
80 end
81
81
82 cattr_accessor :ignored_emails_headers
82 cattr_accessor :ignored_emails_headers
83 self.ignored_emails_headers = {
83 self.ignored_emails_headers = {
84 'Auto-Submitted' => /\Aauto-(replied|generated)/,
84 'Auto-Submitted' => /\Aauto-(replied|generated)/,
85 'X-Autoreply' => 'yes'
85 'X-Autoreply' => 'yes'
86 }
86 }
87
87
88 # Processes incoming emails
88 # Processes incoming emails
89 # Returns the created object (eg. an issue, a message) or false
89 # Returns the created object (eg. an issue, a message) or false
90 def receive(email, options={})
90 def receive(email, options={})
91 @email = email
91 @email = email
92 @handler_options = options
92 @handler_options = options
93 sender_email = email.from.to_a.first.to_s.strip
93 sender_email = email.from.to_a.first.to_s.strip
94 # Ignore emails received from the application emission address to avoid hell cycles
94 # Ignore emails received from the application emission address to avoid hell cycles
95 if sender_email.downcase == Setting.mail_from.to_s.strip.downcase
95 if sender_email.downcase == Setting.mail_from.to_s.strip.downcase
96 if logger
96 if logger
97 logger.info "MailHandler: ignoring email from Redmine emission address [#{sender_email}]"
97 logger.info "MailHandler: ignoring email from Redmine emission address [#{sender_email}]"
98 end
98 end
99 return false
99 return false
100 end
100 end
101 # Ignore auto generated emails
101 # Ignore auto generated emails
102 self.class.ignored_emails_headers.each do |key, ignored_value|
102 self.class.ignored_emails_headers.each do |key, ignored_value|
103 value = email.header[key]
103 value = email.header[key]
104 if value
104 if value
105 value = value.to_s.downcase
105 value = value.to_s.downcase
106 if (ignored_value.is_a?(Regexp) && value.match(ignored_value)) || value == ignored_value
106 if (ignored_value.is_a?(Regexp) && value.match(ignored_value)) || value == ignored_value
107 if logger
107 if logger
108 logger.info "MailHandler: ignoring email with #{key}:#{value} header"
108 logger.info "MailHandler: ignoring email with #{key}:#{value} header"
109 end
109 end
110 return false
110 return false
111 end
111 end
112 end
112 end
113 end
113 end
114 @user = User.find_by_mail(sender_email) if sender_email.present?
114 @user = User.find_by_mail(sender_email) if sender_email.present?
115 if @user && !@user.active?
115 if @user && !@user.active?
116 if logger
116 if logger
117 logger.info "MailHandler: ignoring email from non-active user [#{@user.login}]"
117 logger.info "MailHandler: ignoring email from non-active user [#{@user.login}]"
118 end
118 end
119 return false
119 return false
120 end
120 end
121 if @user.nil?
121 if @user.nil?
122 # Email was submitted by an unknown user
122 # Email was submitted by an unknown user
123 case handler_options[:unknown_user]
123 case handler_options[:unknown_user]
124 when 'accept'
124 when 'accept'
125 @user = User.anonymous
125 @user = User.anonymous
126 when 'create'
126 when 'create'
127 @user = create_user_from_email
127 @user = create_user_from_email
128 if @user
128 if @user
129 if logger
129 if logger
130 logger.info "MailHandler: [#{@user.login}] account created"
130 logger.info "MailHandler: [#{@user.login}] account created"
131 end
131 end
132 add_user_to_group(handler_options[:default_group])
132 add_user_to_group(handler_options[:default_group])
133 unless handler_options[:no_account_notice]
133 unless handler_options[:no_account_notice]
134 Mailer.account_information(@user, @user.password).deliver
134 Mailer.account_information(@user, @user.password).deliver
135 end
135 end
136 else
136 else
137 if logger
137 if logger
138 logger.error "MailHandler: could not create account for [#{sender_email}]"
138 logger.error "MailHandler: could not create account for [#{sender_email}]"
139 end
139 end
140 return false
140 return false
141 end
141 end
142 else
142 else
143 # Default behaviour, emails from unknown users are ignored
143 # Default behaviour, emails from unknown users are ignored
144 if logger
144 if logger
145 logger.info "MailHandler: ignoring email from unknown user [#{sender_email}]"
145 logger.info "MailHandler: ignoring email from unknown user [#{sender_email}]"
146 end
146 end
147 return false
147 return false
148 end
148 end
149 end
149 end
150 User.current = @user
150 User.current = @user
151 dispatch
151 dispatch
152 end
152 end
153
153
154 private
154 private
155
155
156 MESSAGE_ID_RE = %r{^<?redmine\.([a-z0-9_]+)\-(\d+)\.\d+(\.[a-f0-9]+)?@}
156 MESSAGE_ID_RE = %r{^<?redmine\.([a-z0-9_]+)\-(\d+)\.\d+(\.[a-f0-9]+)?@}
157 ISSUE_REPLY_SUBJECT_RE = %r{\[(?:[^\]]*\s+)?#(\d+)\]}
157 ISSUE_REPLY_SUBJECT_RE = %r{\[(?:[^\]]*\s+)?#(\d+)\]}
158 MESSAGE_REPLY_SUBJECT_RE = %r{\[[^\]]*msg(\d+)\]}
158 MESSAGE_REPLY_SUBJECT_RE = %r{\[[^\]]*msg(\d+)\]}
159
159
160 def dispatch
160 def dispatch
161 headers = [email.in_reply_to, email.references].flatten.compact
161 headers = [email.in_reply_to, email.references].flatten.compact
162 subject = email.subject.to_s
162 subject = email.subject.to_s
163 if headers.detect {|h| h.to_s =~ MESSAGE_ID_RE}
163 if headers.detect {|h| h.to_s =~ MESSAGE_ID_RE}
164 klass, object_id = $1, $2.to_i
164 klass, object_id = $1, $2.to_i
165 method_name = "receive_#{klass}_reply"
165 method_name = "receive_#{klass}_reply"
166 if self.class.private_instance_methods.collect(&:to_s).include?(method_name)
166 if self.class.private_instance_methods.collect(&:to_s).include?(method_name)
167 send method_name, object_id
167 send method_name, object_id
168 else
168 else
169 # ignoring it
169 # ignoring it
170 end
170 end
171 elsif m = subject.match(ISSUE_REPLY_SUBJECT_RE)
171 elsif m = subject.match(ISSUE_REPLY_SUBJECT_RE)
172 receive_issue_reply(m[1].to_i)
172 receive_issue_reply(m[1].to_i)
173 elsif m = subject.match(MESSAGE_REPLY_SUBJECT_RE)
173 elsif m = subject.match(MESSAGE_REPLY_SUBJECT_RE)
174 receive_message_reply(m[1].to_i)
174 receive_message_reply(m[1].to_i)
175 else
175 else
176 dispatch_to_default
176 dispatch_to_default
177 end
177 end
178 rescue ActiveRecord::RecordInvalid => e
178 rescue ActiveRecord::RecordInvalid => e
179 # TODO: send a email to the user
179 # TODO: send a email to the user
180 logger.error e.message if logger
180 logger.error e.message if logger
181 false
181 false
182 rescue MissingInformation => e
182 rescue MissingInformation => e
183 logger.error "MailHandler: missing information from #{user}: #{e.message}" if logger
183 logger.error "MailHandler: missing information from #{user}: #{e.message}" if logger
184 false
184 false
185 rescue UnauthorizedAction => e
185 rescue UnauthorizedAction => e
186 logger.error "MailHandler: unauthorized attempt from #{user}" if logger
186 logger.error "MailHandler: unauthorized attempt from #{user}" if logger
187 false
187 false
188 end
188 end
189
189
190 def dispatch_to_default
190 def dispatch_to_default
191 receive_issue
191 receive_issue
192 end
192 end
193
193
194 # Creates a new issue
194 # Creates a new issue
195 def receive_issue
195 def receive_issue
196 project = target_project
196 project = target_project
197 # check permission
197 # check permission
198 unless handler_options[:no_permission_check]
198 unless handler_options[:no_permission_check]
199 raise UnauthorizedAction unless user.allowed_to?(:add_issues, project)
199 raise UnauthorizedAction unless user.allowed_to?(:add_issues, project)
200 end
200 end
201
201
202 issue = Issue.new(:author => user, :project => project)
202 issue = Issue.new(:author => user, :project => project)
203 issue.safe_attributes = issue_attributes_from_keywords(issue)
203 issue.safe_attributes = issue_attributes_from_keywords(issue)
204 issue.safe_attributes = {'custom_field_values' => custom_field_values_from_keywords(issue)}
204 issue.safe_attributes = {'custom_field_values' => custom_field_values_from_keywords(issue)}
205 issue.subject = cleaned_up_subject
205 issue.subject = cleaned_up_subject
206 if issue.subject.blank?
206 if issue.subject.blank?
207 issue.subject = '(no subject)'
207 issue.subject = '(no subject)'
208 end
208 end
209 issue.description = cleaned_up_text_body
209 issue.description = cleaned_up_text_body
210 issue.start_date ||= Date.today if Setting.default_issue_start_date_to_creation_date?
210 issue.start_date ||= Date.today if Setting.default_issue_start_date_to_creation_date?
211 issue.is_private = (handler_options[:issue][:is_private] == '1')
211 issue.is_private = (handler_options[:issue][:is_private] == '1')
212
212
213 # add To and Cc as watchers before saving so the watchers can reply to Redmine
213 # add To and Cc as watchers before saving so the watchers can reply to Redmine
214 add_watchers(issue)
214 add_watchers(issue)
215 issue.save!
215 issue.save!
216 add_attachments(issue)
216 add_attachments(issue)
217 logger.info "MailHandler: issue ##{issue.id} created by #{user}" if logger
217 logger.info "MailHandler: issue ##{issue.id} created by #{user}" if logger
218 issue
218 issue
219 end
219 end
220
220
221 # Adds a note to an existing issue
221 # Adds a note to an existing issue
222 def receive_issue_reply(issue_id, from_journal=nil)
222 def receive_issue_reply(issue_id, from_journal=nil)
223 issue = Issue.find_by_id(issue_id)
223 issue = Issue.find_by_id(issue_id)
224 return unless issue
224 return unless issue
225 # check permission
225 # check permission
226 unless handler_options[:no_permission_check]
226 unless handler_options[:no_permission_check]
227 unless user.allowed_to?(:add_issue_notes, issue.project) ||
227 unless user.allowed_to?(:add_issue_notes, issue.project) ||
228 user.allowed_to?(:edit_issues, issue.project)
228 user.allowed_to?(:edit_issues, issue.project)
229 raise UnauthorizedAction
229 raise UnauthorizedAction
230 end
230 end
231 end
231 end
232
232
233 # ignore CLI-supplied defaults for new issues
233 # ignore CLI-supplied defaults for new issues
234 handler_options[:issue].clear
234 handler_options[:issue].clear
235
235
236 journal = issue.init_journal(user)
236 journal = issue.init_journal(user)
237 if from_journal && from_journal.private_notes?
237 if from_journal && from_journal.private_notes?
238 # If the received email was a reply to a private note, make the added note private
238 # If the received email was a reply to a private note, make the added note private
239 issue.private_notes = true
239 issue.private_notes = true
240 end
240 end
241 issue.safe_attributes = issue_attributes_from_keywords(issue)
241 issue.safe_attributes = issue_attributes_from_keywords(issue)
242 issue.safe_attributes = {'custom_field_values' => custom_field_values_from_keywords(issue)}
242 issue.safe_attributes = {'custom_field_values' => custom_field_values_from_keywords(issue)}
243 journal.notes = cleaned_up_text_body
243 journal.notes = cleaned_up_text_body
244 add_attachments(issue)
244 add_attachments(issue)
245 issue.save!
245 issue.save!
246 if logger
246 if logger
247 logger.info "MailHandler: issue ##{issue.id} updated by #{user}"
247 logger.info "MailHandler: issue ##{issue.id} updated by #{user}"
248 end
248 end
249 journal
249 journal
250 end
250 end
251
251
252 # Reply will be added to the issue
252 # Reply will be added to the issue
253 def receive_journal_reply(journal_id)
253 def receive_journal_reply(journal_id)
254 journal = Journal.find_by_id(journal_id)
254 journal = Journal.find_by_id(journal_id)
255 if journal && journal.journalized_type == 'Issue'
255 if journal && journal.journalized_type == 'Issue'
256 receive_issue_reply(journal.journalized_id, journal)
256 receive_issue_reply(journal.journalized_id, journal)
257 end
257 end
258 end
258 end
259
259
260 # Receives a reply to a forum message
260 # Receives a reply to a forum message
261 def receive_message_reply(message_id)
261 def receive_message_reply(message_id)
262 message = Message.find_by_id(message_id)
262 message = Message.find_by_id(message_id)
263 if message
263 if message
264 message = message.root
264 message = message.root
265
265
266 unless handler_options[:no_permission_check]
266 unless handler_options[:no_permission_check]
267 raise UnauthorizedAction unless user.allowed_to?(:add_messages, message.project)
267 raise UnauthorizedAction unless user.allowed_to?(:add_messages, message.project)
268 end
268 end
269
269
270 if !message.locked?
270 if !message.locked?
271 reply = Message.new(:subject => cleaned_up_subject.gsub(%r{^.*msg\d+\]}, '').strip,
271 reply = Message.new(:subject => cleaned_up_subject.gsub(%r{^.*msg\d+\]}, '').strip,
272 :content => cleaned_up_text_body)
272 :content => cleaned_up_text_body)
273 reply.author = user
273 reply.author = user
274 reply.board = message.board
274 reply.board = message.board
275 message.children << reply
275 message.children << reply
276 add_attachments(reply)
276 add_attachments(reply)
277 reply
277 reply
278 else
278 else
279 if logger
279 if logger
280 logger.info "MailHandler: ignoring reply from [#{sender_email}] to a locked topic"
280 logger.info "MailHandler: ignoring reply from [#{sender_email}] to a locked topic"
281 end
281 end
282 end
282 end
283 end
283 end
284 end
284 end
285
285
286 def add_attachments(obj)
286 def add_attachments(obj)
287 if email.attachments && email.attachments.any?
287 if email.attachments && email.attachments.any?
288 email.attachments.each do |attachment|
288 email.attachments.each do |attachment|
289 next unless accept_attachment?(attachment)
289 next unless accept_attachment?(attachment)
290 obj.attachments << Attachment.create(:container => obj,
290 obj.attachments << Attachment.create(:container => obj,
291 :file => attachment.decoded,
291 :file => attachment.decoded,
292 :filename => attachment.filename,
292 :filename => attachment.filename,
293 :author => user,
293 :author => user,
294 :content_type => attachment.mime_type)
294 :content_type => attachment.mime_type)
295 end
295 end
296 end
296 end
297 end
297 end
298
298
299 # Returns false if the +attachment+ of the incoming email should be ignored
299 # Returns false if the +attachment+ of the incoming email should be ignored
300 def accept_attachment?(attachment)
300 def accept_attachment?(attachment)
301 @excluded ||= Setting.mail_handler_excluded_filenames.to_s.split(',').map(&:strip).reject(&:blank?)
301 @excluded ||= Setting.mail_handler_excluded_filenames.to_s.split(',').map(&:strip).reject(&:blank?)
302 @excluded.each do |pattern|
302 @excluded.each do |pattern|
303 regexp = %r{\A#{Regexp.escape(pattern).gsub("\\*", ".*")}\z}i
303 regexp = %r{\A#{Regexp.escape(pattern).gsub("\\*", ".*")}\z}i
304 if attachment.filename.to_s =~ regexp
304 if attachment.filename.to_s =~ regexp
305 logger.info "MailHandler: ignoring attachment #{attachment.filename} matching #{pattern}"
305 logger.info "MailHandler: ignoring attachment #{attachment.filename} matching #{pattern}"
306 return false
306 return false
307 end
307 end
308 end
308 end
309 true
309 true
310 end
310 end
311
311
312 # Adds To and Cc as watchers of the given object if the sender has the
312 # Adds To and Cc as watchers of the given object if the sender has the
313 # appropriate permission
313 # appropriate permission
314 def add_watchers(obj)
314 def add_watchers(obj)
315 if user.allowed_to?("add_#{obj.class.name.underscore}_watchers".to_sym, obj.project)
315 if user.allowed_to?("add_#{obj.class.name.underscore}_watchers".to_sym, obj.project)
316 addresses = [email.to, email.cc].flatten.compact.uniq.collect {|a| a.strip.downcase}
316 addresses = [email.to, email.cc].flatten.compact.uniq.collect {|a| a.strip.downcase}
317 unless addresses.empty?
317 unless addresses.empty?
318 User.active.having_mail(addresses).each do |w|
318 User.active.having_mail(addresses).each do |w|
319 obj.add_watcher(w)
319 obj.add_watcher(w)
320 end
320 end
321 end
321 end
322 end
322 end
323 end
323 end
324
324
325 def get_keyword(attr, options={})
325 def get_keyword(attr, options={})
326 @keywords ||= {}
326 @keywords ||= {}
327 if @keywords.has_key?(attr)
327 if @keywords.has_key?(attr)
328 @keywords[attr]
328 @keywords[attr]
329 else
329 else
330 @keywords[attr] = begin
330 @keywords[attr] = begin
331 if (options[:override] || handler_options[:allow_override].include?(attr.to_s)) &&
331 if (options[:override] || handler_options[:allow_override].include?(attr.to_s)) &&
332 (v = extract_keyword!(cleaned_up_text_body, attr, options[:format]))
332 (v = extract_keyword!(cleaned_up_text_body, attr, options[:format]))
333 v
333 v
334 elsif !handler_options[:issue][attr].blank?
334 elsif !handler_options[:issue][attr].blank?
335 handler_options[:issue][attr]
335 handler_options[:issue][attr]
336 end
336 end
337 end
337 end
338 end
338 end
339 end
339 end
340
340
341 # Destructively extracts the value for +attr+ in +text+
341 # Destructively extracts the value for +attr+ in +text+
342 # Returns nil if no matching keyword found
342 # Returns nil if no matching keyword found
343 def extract_keyword!(text, attr, format=nil)
343 def extract_keyword!(text, attr, format=nil)
344 keys = [attr.to_s.humanize]
344 keys = [attr.to_s.humanize]
345 if attr.is_a?(Symbol)
345 if attr.is_a?(Symbol)
346 if user && user.language.present?
346 if user && user.language.present?
347 keys << l("field_#{attr}", :default => '', :locale => user.language)
347 keys << l("field_#{attr}", :default => '', :locale => user.language)
348 end
348 end
349 if Setting.default_language.present?
349 if Setting.default_language.present?
350 keys << l("field_#{attr}", :default => '', :locale => Setting.default_language)
350 keys << l("field_#{attr}", :default => '', :locale => Setting.default_language)
351 end
351 end
352 end
352 end
353 keys.reject! {|k| k.blank?}
353 keys.reject! {|k| k.blank?}
354 keys.collect! {|k| Regexp.escape(k)}
354 keys.collect! {|k| Regexp.escape(k)}
355 format ||= '.+'
355 format ||= '.+'
356 keyword = nil
356 keyword = nil
357 regexp = /^(#{keys.join('|')})[ \t]*:[ \t]*(#{format})\s*$/i
357 regexp = /^(#{keys.join('|')})[ \t]*:[ \t]*(#{format})\s*$/i
358 if m = text.match(regexp)
358 if m = text.match(regexp)
359 keyword = m[2].strip
359 keyword = m[2].strip
360 text.sub!(regexp, '')
360 text.sub!(regexp, '')
361 end
361 end
362 keyword
362 keyword
363 end
363 end
364
364
365 def target_project
365 def target_project
366 # TODO: other ways to specify project:
366 # TODO: other ways to specify project:
367 # * parse the email To field
367 # * parse the email To field
368 # * specific project (eg. Setting.mail_handler_target_project)
368 # * specific project (eg. Setting.mail_handler_target_project)
369 target = Project.find_by_identifier(get_keyword(:project))
369 target = Project.find_by_identifier(get_keyword(:project))
370 if target.nil?
370 if target.nil?
371 # Invalid project keyword, use the project specified as the default one
371 # Invalid project keyword, use the project specified as the default one
372 default_project = handler_options[:issue][:project]
372 default_project = handler_options[:issue][:project]
373 if default_project.present?
373 if default_project.present?
374 target = Project.find_by_identifier(default_project)
374 target = Project.find_by_identifier(default_project)
375 end
375 end
376 end
376 end
377 raise MissingInformation.new('Unable to determine target project') if target.nil?
377 raise MissingInformation.new('Unable to determine target project') if target.nil?
378 target
378 target
379 end
379 end
380
380
381 # Returns a Hash of issue attributes extracted from keywords in the email body
381 # Returns a Hash of issue attributes extracted from keywords in the email body
382 def issue_attributes_from_keywords(issue)
382 def issue_attributes_from_keywords(issue)
383 assigned_to = (k = get_keyword(:assigned_to, :override => true)) && find_assignee_from_keyword(k, issue)
383 assigned_to = (k = get_keyword(:assigned_to, :override => true)) && find_assignee_from_keyword(k, issue)
384
384
385 attrs = {
385 attrs = {
386 'tracker_id' => (k = get_keyword(:tracker)) && issue.project.trackers.named(k).first.try(:id),
386 'tracker_id' => (k = get_keyword(:tracker)) && issue.project.trackers.named(k).first.try(:id),
387 'status_id' => (k = get_keyword(:status)) && IssueStatus.named(k).first.try(:id),
387 'status_id' => (k = get_keyword(:status)) && IssueStatus.named(k).first.try(:id),
388 'priority_id' => (k = get_keyword(:priority)) && IssuePriority.named(k).first.try(:id),
388 'priority_id' => (k = get_keyword(:priority)) && IssuePriority.named(k).first.try(:id),
389 'category_id' => (k = get_keyword(:category)) && issue.project.issue_categories.named(k).first.try(:id),
389 'category_id' => (k = get_keyword(:category)) && issue.project.issue_categories.named(k).first.try(:id),
390 'assigned_to_id' => assigned_to.try(:id),
390 'assigned_to_id' => assigned_to.try(:id),
391 'fixed_version_id' => (k = get_keyword(:fixed_version, :override => true)) &&
391 'fixed_version_id' => (k = get_keyword(:fixed_version, :override => true)) &&
392 issue.project.shared_versions.named(k).first.try(:id),
392 issue.project.shared_versions.named(k).first.try(:id),
393 'start_date' => get_keyword(:start_date, :override => true, :format => '\d{4}-\d{2}-\d{2}'),
393 'start_date' => get_keyword(:start_date, :override => true, :format => '\d{4}-\d{2}-\d{2}'),
394 'due_date' => get_keyword(:due_date, :override => true, :format => '\d{4}-\d{2}-\d{2}'),
394 'due_date' => get_keyword(:due_date, :override => true, :format => '\d{4}-\d{2}-\d{2}'),
395 'estimated_hours' => get_keyword(:estimated_hours, :override => true),
395 'estimated_hours' => get_keyword(:estimated_hours, :override => true),
396 'done_ratio' => get_keyword(:done_ratio, :override => true, :format => '(\d|10)?0')
396 'done_ratio' => get_keyword(:done_ratio, :override => true, :format => '(\d|10)?0')
397 }.delete_if {|k, v| v.blank? }
397 }.delete_if {|k, v| v.blank? }
398
398
399 if issue.new_record? && attrs['tracker_id'].nil?
399 if issue.new_record? && attrs['tracker_id'].nil?
400 attrs['tracker_id'] = issue.project.trackers.first.try(:id)
400 attrs['tracker_id'] = issue.project.trackers.first.try(:id)
401 end
401 end
402
402
403 attrs
403 attrs
404 end
404 end
405
405
406 # Returns a Hash of issue custom field values extracted from keywords in the email body
406 # Returns a Hash of issue custom field values extracted from keywords in the email body
407 def custom_field_values_from_keywords(customized)
407 def custom_field_values_from_keywords(customized)
408 customized.custom_field_values.inject({}) do |h, v|
408 customized.custom_field_values.inject({}) do |h, v|
409 if keyword = get_keyword(v.custom_field.name, :override => true)
409 if keyword = get_keyword(v.custom_field.name, :override => true)
410 h[v.custom_field.id.to_s] = v.custom_field.value_from_keyword(keyword, customized)
410 h[v.custom_field.id.to_s] = v.custom_field.value_from_keyword(keyword, customized)
411 end
411 end
412 h
412 h
413 end
413 end
414 end
414 end
415
415
416 # Returns the text/plain part of the email
416 # Returns the text/plain part of the email
417 # If not found (eg. HTML-only email), returns the body with tags removed
417 # If not found (eg. HTML-only email), returns the body with tags removed
418 def plain_text_body
418 def plain_text_body
419 return @plain_text_body unless @plain_text_body.nil?
419 return @plain_text_body unless @plain_text_body.nil?
420
420
421 parts = if (text_parts = email.all_parts.select {|p| p.mime_type == 'text/plain'}).present?
421 parts = if (text_parts = email.all_parts.select {|p| p.mime_type == 'text/plain'}).present?
422 text_parts
422 text_parts
423 elsif (html_parts = email.all_parts.select {|p| p.mime_type == 'text/html'}).present?
423 elsif (html_parts = email.all_parts.select {|p| p.mime_type == 'text/html'}).present?
424 html_parts
424 html_parts
425 else
425 else
426 [email]
426 [email]
427 end
427 end
428
428
429 parts.reject! do |part|
429 parts.reject! do |part|
430 part.attachment?
430 part.attachment?
431 end
431 end
432
432
433 @plain_text_body = parts.map do |p|
433 @plain_text_body = parts.map do |p|
434 body_charset = Mail::RubyVer.respond_to?(:pick_encoding) ?
434 body_charset = Mail::RubyVer.respond_to?(:pick_encoding) ?
435 Mail::RubyVer.pick_encoding(p.charset).to_s : p.charset
435 Mail::RubyVer.pick_encoding(p.charset).to_s : p.charset
436 Redmine::CodesetUtil.to_utf8(p.body.decoded, body_charset)
437 end.join("\r\n")
438
436
439 # strip html tags and remove doctype directive
437 body = Redmine::CodesetUtil.to_utf8(p.body.decoded, body_charset)
440 if parts.any? {|p| p.mime_type == 'text/html'}
438 # convert html parts to text
441 @plain_text_body = strip_tags(@plain_text_body.strip)
439 p.mime_type == 'text/html' ? self.class.html_body_to_text(body) : body
442 @plain_text_body.sub! %r{^<!DOCTYPE .*$}, ''
440 end.join("\r\n")
443 end
444
441
445 @plain_text_body
442 @plain_text_body
446 end
443 end
447
444
448 def cleaned_up_text_body
445 def cleaned_up_text_body
449 @cleaned_up_text_body ||= cleanup_body(plain_text_body)
446 @cleaned_up_text_body ||= cleanup_body(plain_text_body)
450 end
447 end
451
448
452 def cleaned_up_subject
449 def cleaned_up_subject
453 subject = email.subject.to_s
450 subject = email.subject.to_s
454 subject.strip[0,255]
451 subject.strip[0,255]
455 end
452 end
456
453
454 # Converts a HTML email body to text
455 def self.html_body_to_text(html)
456 Redmine::WikiFormatting.html_parser.to_text(html)
457 end
458
457 def self.assign_string_attribute_with_limit(object, attribute, value, limit=nil)
459 def self.assign_string_attribute_with_limit(object, attribute, value, limit=nil)
458 limit ||= object.class.columns_hash[attribute.to_s].limit || 255
460 limit ||= object.class.columns_hash[attribute.to_s].limit || 255
459 value = value.to_s.slice(0, limit)
461 value = value.to_s.slice(0, limit)
460 object.send("#{attribute}=", value)
462 object.send("#{attribute}=", value)
461 end
463 end
462
464
463 # Returns a User from an email address and a full name
465 # Returns a User from an email address and a full name
464 def self.new_user_from_attributes(email_address, fullname=nil)
466 def self.new_user_from_attributes(email_address, fullname=nil)
465 user = User.new
467 user = User.new
466
468
467 # Truncating the email address would result in an invalid format
469 # Truncating the email address would result in an invalid format
468 user.mail = email_address
470 user.mail = email_address
469 assign_string_attribute_with_limit(user, 'login', email_address, User::LOGIN_LENGTH_LIMIT)
471 assign_string_attribute_with_limit(user, 'login', email_address, User::LOGIN_LENGTH_LIMIT)
470
472
471 names = fullname.blank? ? email_address.gsub(/@.*$/, '').split('.') : fullname.split
473 names = fullname.blank? ? email_address.gsub(/@.*$/, '').split('.') : fullname.split
472 assign_string_attribute_with_limit(user, 'firstname', names.shift, 30)
474 assign_string_attribute_with_limit(user, 'firstname', names.shift, 30)
473 assign_string_attribute_with_limit(user, 'lastname', names.join(' '), 30)
475 assign_string_attribute_with_limit(user, 'lastname', names.join(' '), 30)
474 user.lastname = '-' if user.lastname.blank?
476 user.lastname = '-' if user.lastname.blank?
475 user.language = Setting.default_language
477 user.language = Setting.default_language
476 user.generate_password = true
478 user.generate_password = true
477 user.mail_notification = 'only_my_events'
479 user.mail_notification = 'only_my_events'
478
480
479 unless user.valid?
481 unless user.valid?
480 user.login = "user#{Redmine::Utils.random_hex(6)}" unless user.errors[:login].blank?
482 user.login = "user#{Redmine::Utils.random_hex(6)}" unless user.errors[:login].blank?
481 user.firstname = "-" unless user.errors[:firstname].blank?
483 user.firstname = "-" unless user.errors[:firstname].blank?
482 (puts user.errors[:lastname];user.lastname = "-") unless user.errors[:lastname].blank?
484 (puts user.errors[:lastname];user.lastname = "-") unless user.errors[:lastname].blank?
483 end
485 end
484
486
485 user
487 user
486 end
488 end
487
489
488 # Creates a User for the +email+ sender
490 # Creates a User for the +email+ sender
489 # Returns the user or nil if it could not be created
491 # Returns the user or nil if it could not be created
490 def create_user_from_email
492 def create_user_from_email
491 from = email.header['from'].to_s
493 from = email.header['from'].to_s
492 addr, name = from, nil
494 addr, name = from, nil
493 if m = from.match(/^"?(.+?)"?\s+<(.+@.+)>$/)
495 if m = from.match(/^"?(.+?)"?\s+<(.+@.+)>$/)
494 addr, name = m[2], m[1]
496 addr, name = m[2], m[1]
495 end
497 end
496 if addr.present?
498 if addr.present?
497 user = self.class.new_user_from_attributes(addr, name)
499 user = self.class.new_user_from_attributes(addr, name)
498 if handler_options[:no_notification]
500 if handler_options[:no_notification]
499 user.mail_notification = 'none'
501 user.mail_notification = 'none'
500 end
502 end
501 if user.save
503 if user.save
502 user
504 user
503 else
505 else
504 logger.error "MailHandler: failed to create User: #{user.errors.full_messages}" if logger
506 logger.error "MailHandler: failed to create User: #{user.errors.full_messages}" if logger
505 nil
507 nil
506 end
508 end
507 else
509 else
508 logger.error "MailHandler: failed to create User: no FROM address found" if logger
510 logger.error "MailHandler: failed to create User: no FROM address found" if logger
509 nil
511 nil
510 end
512 end
511 end
513 end
512
514
513 # Adds the newly created user to default group
515 # Adds the newly created user to default group
514 def add_user_to_group(default_group)
516 def add_user_to_group(default_group)
515 if default_group.present?
517 if default_group.present?
516 default_group.split(',').each do |group_name|
518 default_group.split(',').each do |group_name|
517 if group = Group.named(group_name).first
519 if group = Group.named(group_name).first
518 group.users << @user
520 group.users << @user
519 elsif logger
521 elsif logger
520 logger.warn "MailHandler: could not add user to [#{group_name}], group not found"
522 logger.warn "MailHandler: could not add user to [#{group_name}], group not found"
521 end
523 end
522 end
524 end
523 end
525 end
524 end
526 end
525
527
526 # Removes the email body of text after the truncation configurations.
528 # Removes the email body of text after the truncation configurations.
527 def cleanup_body(body)
529 def cleanup_body(body)
528 delimiters = Setting.mail_handler_body_delimiters.to_s.split(/[\r\n]+/).reject(&:blank?).map {|s| Regexp.escape(s)}
530 delimiters = Setting.mail_handler_body_delimiters.to_s.split(/[\r\n]+/).reject(&:blank?).map {|s| Regexp.escape(s)}
529 unless delimiters.empty?
531 unless delimiters.empty?
530 regex = Regexp.new("^[> ]*(#{ delimiters.join('|') })\s*[\r\n].*", Regexp::MULTILINE)
532 regex = Regexp.new("^[> ]*(#{ delimiters.join('|') })\s*[\r\n].*", Regexp::MULTILINE)
531 body = body.gsub(regex, '')
533 body = body.gsub(regex, '')
532 end
534 end
533 body.strip
535 body.strip
534 end
536 end
535
537
536 def find_assignee_from_keyword(keyword, issue)
538 def find_assignee_from_keyword(keyword, issue)
537 keyword = keyword.to_s.downcase
539 keyword = keyword.to_s.downcase
538 assignable = issue.assignable_users
540 assignable = issue.assignable_users
539 assignee = nil
541 assignee = nil
540 assignee ||= assignable.detect {|a|
542 assignee ||= assignable.detect {|a|
541 a.mail.to_s.downcase == keyword ||
543 a.mail.to_s.downcase == keyword ||
542 a.login.to_s.downcase == keyword
544 a.login.to_s.downcase == keyword
543 }
545 }
544 if assignee.nil? && keyword.match(/ /)
546 if assignee.nil? && keyword.match(/ /)
545 firstname, lastname = *(keyword.split) # "First Last Throwaway"
547 firstname, lastname = *(keyword.split) # "First Last Throwaway"
546 assignee ||= assignable.detect {|a|
548 assignee ||= assignable.detect {|a|
547 a.is_a?(User) && a.firstname.to_s.downcase == firstname &&
549 a.is_a?(User) && a.firstname.to_s.downcase == firstname &&
548 a.lastname.to_s.downcase == lastname
550 a.lastname.to_s.downcase == lastname
549 }
551 }
550 end
552 end
551 if assignee.nil?
553 if assignee.nil?
552 assignee ||= assignable.detect {|a| a.name.downcase == keyword}
554 assignee ||= assignable.detect {|a| a.name.downcase == keyword}
553 end
555 end
554 assignee
556 assignee
555 end
557 end
556 end
558 end
@@ -1,277 +1,274
1 # Redmine - project management software
1 # Redmine - project management software
2 # Copyright (C) 2006-2015 Jean-Philippe Lang
2 # Copyright (C) 2006-2015 Jean-Philippe Lang
3 #
3 #
4 # This program is free software; you can redistribute it and/or
4 # This program is free software; you can redistribute it and/or
5 # modify it under the terms of the GNU General Public License
5 # modify it under the terms of the GNU General Public License
6 # as published by the Free Software Foundation; either version 2
6 # as published by the Free Software Foundation; either version 2
7 # of the License, or (at your option) any later version.
7 # of the License, or (at your option) any later version.
8 #
8 #
9 # This program is distributed in the hope that it will be useful,
9 # This program is distributed in the hope that it will be useful,
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 # GNU General Public License for more details.
12 # GNU General Public License for more details.
13 #
13 #
14 # You should have received a copy of the GNU General Public License
14 # You should have received a copy of the GNU General Public License
15 # along with this program; if not, write to the Free Software
15 # along with this program; if not, write to the Free Software
16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
17
17
18 require 'redmine/core_ext'
18 require 'redmine/core_ext'
19
19
20 begin
20 begin
21 require 'rmagick' unless Object.const_defined?(:Magick)
21 require 'rmagick' unless Object.const_defined?(:Magick)
22 rescue LoadError
22 rescue LoadError
23 # RMagick is not available
23 # RMagick is not available
24 end
24 end
25 begin
25 begin
26 require 'redcarpet' unless Object.const_defined?(:Redcarpet)
26 require 'redcarpet' unless Object.const_defined?(:Redcarpet)
27 rescue LoadError
27 rescue LoadError
28 # Redcarpet is not available
28 # Redcarpet is not available
29 end
29 end
30
30
31 require 'redmine/scm/base'
31 require 'redmine/scm/base'
32 require 'redmine/access_control'
32 require 'redmine/access_control'
33 require 'redmine/access_keys'
33 require 'redmine/access_keys'
34 require 'redmine/activity'
34 require 'redmine/activity'
35 require 'redmine/activity/fetcher'
35 require 'redmine/activity/fetcher'
36 require 'redmine/ciphering'
36 require 'redmine/ciphering'
37 require 'redmine/codeset_util'
37 require 'redmine/codeset_util'
38 require 'redmine/field_format'
38 require 'redmine/field_format'
39 require 'redmine/i18n'
39 require 'redmine/i18n'
40 require 'redmine/menu_manager'
40 require 'redmine/menu_manager'
41 require 'redmine/notifiable'
41 require 'redmine/notifiable'
42 require 'redmine/platform'
42 require 'redmine/platform'
43 require 'redmine/mime_type'
43 require 'redmine/mime_type'
44 require 'redmine/notifiable'
44 require 'redmine/notifiable'
45 require 'redmine/search'
45 require 'redmine/search'
46 require 'redmine/syntax_highlighting'
46 require 'redmine/syntax_highlighting'
47 require 'redmine/thumbnail'
47 require 'redmine/thumbnail'
48 require 'redmine/unified_diff'
48 require 'redmine/unified_diff'
49 require 'redmine/utils'
49 require 'redmine/utils'
50 require 'redmine/version'
50 require 'redmine/version'
51 require 'redmine/wiki_formatting'
51 require 'redmine/wiki_formatting'
52
52
53 require 'redmine/default_data/loader'
53 require 'redmine/default_data/loader'
54 require 'redmine/helpers/calendar'
54 require 'redmine/helpers/calendar'
55 require 'redmine/helpers/diff'
55 require 'redmine/helpers/diff'
56 require 'redmine/helpers/gantt'
56 require 'redmine/helpers/gantt'
57 require 'redmine/helpers/time_report'
57 require 'redmine/helpers/time_report'
58 require 'redmine/views/other_formats_builder'
58 require 'redmine/views/other_formats_builder'
59 require 'redmine/views/labelled_form_builder'
59 require 'redmine/views/labelled_form_builder'
60 require 'redmine/views/builders'
60 require 'redmine/views/builders'
61
61
62 require 'redmine/themes'
62 require 'redmine/themes'
63 require 'redmine/hook'
63 require 'redmine/hook'
64 require 'redmine/plugin'
64 require 'redmine/plugin'
65
65
66 Redmine::Scm::Base.add "Subversion"
66 Redmine::Scm::Base.add "Subversion"
67 Redmine::Scm::Base.add "Darcs"
67 Redmine::Scm::Base.add "Darcs"
68 Redmine::Scm::Base.add "Mercurial"
68 Redmine::Scm::Base.add "Mercurial"
69 Redmine::Scm::Base.add "Cvs"
69 Redmine::Scm::Base.add "Cvs"
70 Redmine::Scm::Base.add "Bazaar"
70 Redmine::Scm::Base.add "Bazaar"
71 Redmine::Scm::Base.add "Git"
71 Redmine::Scm::Base.add "Git"
72 Redmine::Scm::Base.add "Filesystem"
72 Redmine::Scm::Base.add "Filesystem"
73
73
74 # Permissions
74 # Permissions
75 Redmine::AccessControl.map do |map|
75 Redmine::AccessControl.map do |map|
76 map.permission :view_project, {:projects => [:show], :activities => [:index]}, :public => true, :read => true
76 map.permission :view_project, {:projects => [:show], :activities => [:index]}, :public => true, :read => true
77 map.permission :search_project, {:search => :index}, :public => true, :read => true
77 map.permission :search_project, {:search => :index}, :public => true, :read => true
78 map.permission :add_project, {:projects => [:new, :create]}, :require => :loggedin
78 map.permission :add_project, {:projects => [:new, :create]}, :require => :loggedin
79 map.permission :edit_project, {:projects => [:settings, :edit, :update]}, :require => :member
79 map.permission :edit_project, {:projects => [:settings, :edit, :update]}, :require => :member
80 map.permission :close_project, {:projects => [:close, :reopen]}, :require => :member, :read => true
80 map.permission :close_project, {:projects => [:close, :reopen]}, :require => :member, :read => true
81 map.permission :select_project_modules, {:projects => :modules}, :require => :member
81 map.permission :select_project_modules, {:projects => :modules}, :require => :member
82 map.permission :view_members, {:members => [:index, :show]}, :public => true, :read => true
82 map.permission :view_members, {:members => [:index, :show]}, :public => true, :read => true
83 map.permission :manage_members, {:projects => :settings, :members => [:index, :show, :new, :create, :update, :destroy, :autocomplete]}, :require => :member
83 map.permission :manage_members, {:projects => :settings, :members => [:index, :show, :new, :create, :update, :destroy, :autocomplete]}, :require => :member
84 map.permission :manage_versions, {:projects => :settings, :versions => [:new, :create, :edit, :update, :close_completed, :destroy]}, :require => :member
84 map.permission :manage_versions, {:projects => :settings, :versions => [:new, :create, :edit, :update, :close_completed, :destroy]}, :require => :member
85 map.permission :add_subprojects, {:projects => [:new, :create]}, :require => :member
85 map.permission :add_subprojects, {:projects => [:new, :create]}, :require => :member
86
86
87 map.project_module :issue_tracking do |map|
87 map.project_module :issue_tracking do |map|
88 # Issue categories
88 # Issue categories
89 map.permission :manage_categories, {:projects => :settings, :issue_categories => [:index, :show, :new, :create, :edit, :update, :destroy]}, :require => :member
89 map.permission :manage_categories, {:projects => :settings, :issue_categories => [:index, :show, :new, :create, :edit, :update, :destroy]}, :require => :member
90 # Issues
90 # Issues
91 map.permission :view_issues, {:issues => [:index, :show],
91 map.permission :view_issues, {:issues => [:index, :show],
92 :auto_complete => [:issues],
92 :auto_complete => [:issues],
93 :context_menus => [:issues],
93 :context_menus => [:issues],
94 :versions => [:index, :show, :status_by],
94 :versions => [:index, :show, :status_by],
95 :journals => [:index, :diff],
95 :journals => [:index, :diff],
96 :queries => :index,
96 :queries => :index,
97 :reports => [:issue_report, :issue_report_details]},
97 :reports => [:issue_report, :issue_report_details]},
98 :read => true
98 :read => true
99 map.permission :add_issues, {:issues => [:new, :create], :attachments => :upload}
99 map.permission :add_issues, {:issues => [:new, :create], :attachments => :upload}
100 map.permission :edit_issues, {:issues => [:edit, :update, :bulk_edit, :bulk_update], :journals => [:new], :attachments => :upload}
100 map.permission :edit_issues, {:issues => [:edit, :update, :bulk_edit, :bulk_update], :journals => [:new], :attachments => :upload}
101 map.permission :copy_issues, {:issues => [:new, :create, :bulk_edit, :bulk_update], :attachments => :upload}
101 map.permission :copy_issues, {:issues => [:new, :create, :bulk_edit, :bulk_update], :attachments => :upload}
102 map.permission :manage_issue_relations, {:issue_relations => [:index, :show, :create, :destroy]}
102 map.permission :manage_issue_relations, {:issue_relations => [:index, :show, :create, :destroy]}
103 map.permission :manage_subtasks, {}
103 map.permission :manage_subtasks, {}
104 map.permission :set_issues_private, {}
104 map.permission :set_issues_private, {}
105 map.permission :set_own_issues_private, {}, :require => :loggedin
105 map.permission :set_own_issues_private, {}, :require => :loggedin
106 map.permission :add_issue_notes, {:issues => [:edit, :update], :journals => [:new], :attachments => :upload}
106 map.permission :add_issue_notes, {:issues => [:edit, :update], :journals => [:new], :attachments => :upload}
107 map.permission :edit_issue_notes, {:journals => :edit}, :require => :loggedin
107 map.permission :edit_issue_notes, {:journals => :edit}, :require => :loggedin
108 map.permission :edit_own_issue_notes, {:journals => :edit}, :require => :loggedin
108 map.permission :edit_own_issue_notes, {:journals => :edit}, :require => :loggedin
109 map.permission :view_private_notes, {}, :read => true, :require => :member
109 map.permission :view_private_notes, {}, :read => true, :require => :member
110 map.permission :set_notes_private, {}, :require => :member
110 map.permission :set_notes_private, {}, :require => :member
111 map.permission :delete_issues, {:issues => :destroy}, :require => :member
111 map.permission :delete_issues, {:issues => :destroy}, :require => :member
112 # Queries
112 # Queries
113 map.permission :manage_public_queries, {:queries => [:new, :create, :edit, :update, :destroy]}, :require => :member
113 map.permission :manage_public_queries, {:queries => [:new, :create, :edit, :update, :destroy]}, :require => :member
114 map.permission :save_queries, {:queries => [:new, :create, :edit, :update, :destroy]}, :require => :loggedin
114 map.permission :save_queries, {:queries => [:new, :create, :edit, :update, :destroy]}, :require => :loggedin
115 # Watchers
115 # Watchers
116 map.permission :view_issue_watchers, {}, :read => true
116 map.permission :view_issue_watchers, {}, :read => true
117 map.permission :add_issue_watchers, {:watchers => [:new, :create, :append, :autocomplete_for_user]}
117 map.permission :add_issue_watchers, {:watchers => [:new, :create, :append, :autocomplete_for_user]}
118 map.permission :delete_issue_watchers, {:watchers => :destroy}
118 map.permission :delete_issue_watchers, {:watchers => :destroy}
119 end
119 end
120
120
121 map.project_module :time_tracking do |map|
121 map.project_module :time_tracking do |map|
122 map.permission :log_time, {:timelog => [:new, :create]}, :require => :loggedin
122 map.permission :log_time, {:timelog => [:new, :create]}, :require => :loggedin
123 map.permission :view_time_entries, {:timelog => [:index, :report, :show]}, :read => true
123 map.permission :view_time_entries, {:timelog => [:index, :report, :show]}, :read => true
124 map.permission :edit_time_entries, {:timelog => [:edit, :update, :destroy, :bulk_edit, :bulk_update]}, :require => :member
124 map.permission :edit_time_entries, {:timelog => [:edit, :update, :destroy, :bulk_edit, :bulk_update]}, :require => :member
125 map.permission :edit_own_time_entries, {:timelog => [:edit, :update, :destroy,:bulk_edit, :bulk_update]}, :require => :loggedin
125 map.permission :edit_own_time_entries, {:timelog => [:edit, :update, :destroy,:bulk_edit, :bulk_update]}, :require => :loggedin
126 map.permission :manage_project_activities, {:project_enumerations => [:update, :destroy]}, :require => :member
126 map.permission :manage_project_activities, {:project_enumerations => [:update, :destroy]}, :require => :member
127 end
127 end
128
128
129 map.project_module :news do |map|
129 map.project_module :news do |map|
130 map.permission :manage_news, {:news => [:new, :create, :edit, :update, :destroy], :comments => [:destroy], :attachments => :upload}, :require => :member
130 map.permission :manage_news, {:news => [:new, :create, :edit, :update, :destroy], :comments => [:destroy], :attachments => :upload}, :require => :member
131 map.permission :view_news, {:news => [:index, :show]}, :public => true, :read => true
131 map.permission :view_news, {:news => [:index, :show]}, :public => true, :read => true
132 map.permission :comment_news, {:comments => :create}
132 map.permission :comment_news, {:comments => :create}
133 end
133 end
134
134
135 map.project_module :documents do |map|
135 map.project_module :documents do |map|
136 map.permission :add_documents, {:documents => [:new, :create, :add_attachment], :attachments => :upload}, :require => :loggedin
136 map.permission :add_documents, {:documents => [:new, :create, :add_attachment], :attachments => :upload}, :require => :loggedin
137 map.permission :edit_documents, {:documents => [:edit, :update, :add_attachment], :attachments => :upload}, :require => :loggedin
137 map.permission :edit_documents, {:documents => [:edit, :update, :add_attachment], :attachments => :upload}, :require => :loggedin
138 map.permission :delete_documents, {:documents => [:destroy]}, :require => :loggedin
138 map.permission :delete_documents, {:documents => [:destroy]}, :require => :loggedin
139 map.permission :view_documents, {:documents => [:index, :show, :download]}, :read => true
139 map.permission :view_documents, {:documents => [:index, :show, :download]}, :read => true
140 end
140 end
141
141
142 map.project_module :files do |map|
142 map.project_module :files do |map|
143 map.permission :manage_files, {:files => [:new, :create], :attachments => :upload}, :require => :loggedin
143 map.permission :manage_files, {:files => [:new, :create], :attachments => :upload}, :require => :loggedin
144 map.permission :view_files, {:files => :index, :versions => :download}, :read => true
144 map.permission :view_files, {:files => :index, :versions => :download}, :read => true
145 end
145 end
146
146
147 map.project_module :wiki do |map|
147 map.project_module :wiki do |map|
148 map.permission :manage_wiki, {:wikis => [:edit, :destroy]}, :require => :member
148 map.permission :manage_wiki, {:wikis => [:edit, :destroy]}, :require => :member
149 map.permission :rename_wiki_pages, {:wiki => :rename}, :require => :member
149 map.permission :rename_wiki_pages, {:wiki => :rename}, :require => :member
150 map.permission :delete_wiki_pages, {:wiki => [:destroy, :destroy_version]}, :require => :member
150 map.permission :delete_wiki_pages, {:wiki => [:destroy, :destroy_version]}, :require => :member
151 map.permission :view_wiki_pages, {:wiki => [:index, :show, :special, :date_index]}, :read => true
151 map.permission :view_wiki_pages, {:wiki => [:index, :show, :special, :date_index]}, :read => true
152 map.permission :export_wiki_pages, {:wiki => [:export]}, :read => true
152 map.permission :export_wiki_pages, {:wiki => [:export]}, :read => true
153 map.permission :view_wiki_edits, {:wiki => [:history, :diff, :annotate]}, :read => true
153 map.permission :view_wiki_edits, {:wiki => [:history, :diff, :annotate]}, :read => true
154 map.permission :edit_wiki_pages, :wiki => [:edit, :update, :preview, :add_attachment], :attachments => :upload
154 map.permission :edit_wiki_pages, :wiki => [:edit, :update, :preview, :add_attachment], :attachments => :upload
155 map.permission :delete_wiki_pages_attachments, {}
155 map.permission :delete_wiki_pages_attachments, {}
156 map.permission :protect_wiki_pages, {:wiki => :protect}, :require => :member
156 map.permission :protect_wiki_pages, {:wiki => :protect}, :require => :member
157 end
157 end
158
158
159 map.project_module :repository do |map|
159 map.project_module :repository do |map|
160 map.permission :manage_repository, {:repositories => [:new, :create, :edit, :update, :committers, :destroy]}, :require => :member
160 map.permission :manage_repository, {:repositories => [:new, :create, :edit, :update, :committers, :destroy]}, :require => :member
161 map.permission :browse_repository, {:repositories => [:show, :browse, :entry, :raw, :annotate, :changes, :diff, :stats, :graph]}, :read => true
161 map.permission :browse_repository, {:repositories => [:show, :browse, :entry, :raw, :annotate, :changes, :diff, :stats, :graph]}, :read => true
162 map.permission :view_changesets, {:repositories => [:show, :revisions, :revision]}, :read => true
162 map.permission :view_changesets, {:repositories => [:show, :revisions, :revision]}, :read => true
163 map.permission :commit_access, {}
163 map.permission :commit_access, {}
164 map.permission :manage_related_issues, {:repositories => [:add_related_issue, :remove_related_issue]}
164 map.permission :manage_related_issues, {:repositories => [:add_related_issue, :remove_related_issue]}
165 end
165 end
166
166
167 map.project_module :boards do |map|
167 map.project_module :boards do |map|
168 map.permission :manage_boards, {:boards => [:new, :create, :edit, :update, :destroy]}, :require => :member
168 map.permission :manage_boards, {:boards => [:new, :create, :edit, :update, :destroy]}, :require => :member
169 map.permission :view_messages, {:boards => [:index, :show], :messages => [:show]}, :public => true, :read => true
169 map.permission :view_messages, {:boards => [:index, :show], :messages => [:show]}, :public => true, :read => true
170 map.permission :add_messages, {:messages => [:new, :reply, :quote], :attachments => :upload}
170 map.permission :add_messages, {:messages => [:new, :reply, :quote], :attachments => :upload}
171 map.permission :edit_messages, {:messages => :edit, :attachments => :upload}, :require => :member
171 map.permission :edit_messages, {:messages => :edit, :attachments => :upload}, :require => :member
172 map.permission :edit_own_messages, {:messages => :edit, :attachments => :upload}, :require => :loggedin
172 map.permission :edit_own_messages, {:messages => :edit, :attachments => :upload}, :require => :loggedin
173 map.permission :delete_messages, {:messages => :destroy}, :require => :member
173 map.permission :delete_messages, {:messages => :destroy}, :require => :member
174 map.permission :delete_own_messages, {:messages => :destroy}, :require => :loggedin
174 map.permission :delete_own_messages, {:messages => :destroy}, :require => :loggedin
175 end
175 end
176
176
177 map.project_module :calendar do |map|
177 map.project_module :calendar do |map|
178 map.permission :view_calendar, {:calendars => [:show, :update]}, :read => true
178 map.permission :view_calendar, {:calendars => [:show, :update]}, :read => true
179 end
179 end
180
180
181 map.project_module :gantt do |map|
181 map.project_module :gantt do |map|
182 map.permission :view_gantt, {:gantts => [:show, :update]}, :read => true
182 map.permission :view_gantt, {:gantts => [:show, :update]}, :read => true
183 end
183 end
184 end
184 end
185
185
186 Redmine::MenuManager.map :top_menu do |menu|
186 Redmine::MenuManager.map :top_menu do |menu|
187 menu.push :home, :home_path
187 menu.push :home, :home_path
188 menu.push :my_page, { :controller => 'my', :action => 'page' }, :if => Proc.new { User.current.logged? }
188 menu.push :my_page, { :controller => 'my', :action => 'page' }, :if => Proc.new { User.current.logged? }
189 menu.push :projects, { :controller => 'projects', :action => 'index' }, :caption => :label_project_plural
189 menu.push :projects, { :controller => 'projects', :action => 'index' }, :caption => :label_project_plural
190 menu.push :administration, { :controller => 'admin', :action => 'index' }, :if => Proc.new { User.current.admin? }, :last => true
190 menu.push :administration, { :controller => 'admin', :action => 'index' }, :if => Proc.new { User.current.admin? }, :last => true
191 menu.push :help, Redmine::Info.help_url, :last => true
191 menu.push :help, Redmine::Info.help_url, :last => true
192 end
192 end
193
193
194 Redmine::MenuManager.map :account_menu do |menu|
194 Redmine::MenuManager.map :account_menu do |menu|
195 menu.push :login, :signin_path, :if => Proc.new { !User.current.logged? }
195 menu.push :login, :signin_path, :if => Proc.new { !User.current.logged? }
196 menu.push :register, :register_path, :if => Proc.new { !User.current.logged? && Setting.self_registration? }
196 menu.push :register, :register_path, :if => Proc.new { !User.current.logged? && Setting.self_registration? }
197 menu.push :my_account, { :controller => 'my', :action => 'account' }, :if => Proc.new { User.current.logged? }
197 menu.push :my_account, { :controller => 'my', :action => 'account' }, :if => Proc.new { User.current.logged? }
198 menu.push :logout, :signout_path, :html => {:method => 'post'}, :if => Proc.new { User.current.logged? }
198 menu.push :logout, :signout_path, :html => {:method => 'post'}, :if => Proc.new { User.current.logged? }
199 end
199 end
200
200
201 Redmine::MenuManager.map :application_menu do |menu|
201 Redmine::MenuManager.map :application_menu do |menu|
202 # Empty
202 # Empty
203 end
203 end
204
204
205 Redmine::MenuManager.map :admin_menu do |menu|
205 Redmine::MenuManager.map :admin_menu do |menu|
206 menu.push :projects, {:controller => 'admin', :action => 'projects'}, :caption => :label_project_plural
206 menu.push :projects, {:controller => 'admin', :action => 'projects'}, :caption => :label_project_plural
207 menu.push :users, {:controller => 'users'}, :caption => :label_user_plural
207 menu.push :users, {:controller => 'users'}, :caption => :label_user_plural
208 menu.push :groups, {:controller => 'groups'}, :caption => :label_group_plural
208 menu.push :groups, {:controller => 'groups'}, :caption => :label_group_plural
209 menu.push :roles, {:controller => 'roles'}, :caption => :label_role_and_permissions
209 menu.push :roles, {:controller => 'roles'}, :caption => :label_role_and_permissions
210 menu.push :trackers, {:controller => 'trackers'}, :caption => :label_tracker_plural
210 menu.push :trackers, {:controller => 'trackers'}, :caption => :label_tracker_plural
211 menu.push :issue_statuses, {:controller => 'issue_statuses'}, :caption => :label_issue_status_plural,
211 menu.push :issue_statuses, {:controller => 'issue_statuses'}, :caption => :label_issue_status_plural,
212 :html => {:class => 'issue_statuses'}
212 :html => {:class => 'issue_statuses'}
213 menu.push :workflows, {:controller => 'workflows', :action => 'edit'}, :caption => :label_workflow
213 menu.push :workflows, {:controller => 'workflows', :action => 'edit'}, :caption => :label_workflow
214 menu.push :custom_fields, {:controller => 'custom_fields'}, :caption => :label_custom_field_plural,
214 menu.push :custom_fields, {:controller => 'custom_fields'}, :caption => :label_custom_field_plural,
215 :html => {:class => 'custom_fields'}
215 :html => {:class => 'custom_fields'}
216 menu.push :enumerations, {:controller => 'enumerations'}
216 menu.push :enumerations, {:controller => 'enumerations'}
217 menu.push :settings, {:controller => 'settings'}
217 menu.push :settings, {:controller => 'settings'}
218 menu.push :ldap_authentication, {:controller => 'auth_sources', :action => 'index'},
218 menu.push :ldap_authentication, {:controller => 'auth_sources', :action => 'index'},
219 :html => {:class => 'server_authentication'}
219 :html => {:class => 'server_authentication'}
220 menu.push :plugins, {:controller => 'admin', :action => 'plugins'}, :last => true
220 menu.push :plugins, {:controller => 'admin', :action => 'plugins'}, :last => true
221 menu.push :info, {:controller => 'admin', :action => 'info'}, :caption => :label_information_plural, :last => true
221 menu.push :info, {:controller => 'admin', :action => 'info'}, :caption => :label_information_plural, :last => true
222 end
222 end
223
223
224 Redmine::MenuManager.map :project_menu do |menu|
224 Redmine::MenuManager.map :project_menu do |menu|
225 menu.push :overview, { :controller => 'projects', :action => 'show' }
225 menu.push :overview, { :controller => 'projects', :action => 'show' }
226 menu.push :activity, { :controller => 'activities', :action => 'index' }
226 menu.push :activity, { :controller => 'activities', :action => 'index' }
227 menu.push :roadmap, { :controller => 'versions', :action => 'index' }, :param => :project_id,
227 menu.push :roadmap, { :controller => 'versions', :action => 'index' }, :param => :project_id,
228 :if => Proc.new { |p| p.shared_versions.any? }
228 :if => Proc.new { |p| p.shared_versions.any? }
229 menu.push :issues, { :controller => 'issues', :action => 'index' }, :param => :project_id, :caption => :label_issue_plural
229 menu.push :issues, { :controller => 'issues', :action => 'index' }, :param => :project_id, :caption => :label_issue_plural
230 menu.push :new_issue, { :controller => 'issues', :action => 'new', :copy_from => nil }, :param => :project_id, :caption => :label_issue_new,
230 menu.push :new_issue, { :controller => 'issues', :action => 'new', :copy_from => nil }, :param => :project_id, :caption => :label_issue_new,
231 :html => { :accesskey => Redmine::AccessKeys.key_for(:new_issue) },
231 :html => { :accesskey => Redmine::AccessKeys.key_for(:new_issue) },
232 :if => Proc.new { |p| p.trackers.any? },
232 :if => Proc.new { |p| p.trackers.any? },
233 :permission => :add_issues
233 :permission => :add_issues
234 menu.push :gantt, { :controller => 'gantts', :action => 'show' }, :param => :project_id, :caption => :label_gantt
234 menu.push :gantt, { :controller => 'gantts', :action => 'show' }, :param => :project_id, :caption => :label_gantt
235 menu.push :calendar, { :controller => 'calendars', :action => 'show' }, :param => :project_id, :caption => :label_calendar
235 menu.push :calendar, { :controller => 'calendars', :action => 'show' }, :param => :project_id, :caption => :label_calendar
236 menu.push :news, { :controller => 'news', :action => 'index' }, :param => :project_id, :caption => :label_news_plural
236 menu.push :news, { :controller => 'news', :action => 'index' }, :param => :project_id, :caption => :label_news_plural
237 menu.push :documents, { :controller => 'documents', :action => 'index' }, :param => :project_id, :caption => :label_document_plural
237 menu.push :documents, { :controller => 'documents', :action => 'index' }, :param => :project_id, :caption => :label_document_plural
238 menu.push :wiki, { :controller => 'wiki', :action => 'show', :id => nil }, :param => :project_id,
238 menu.push :wiki, { :controller => 'wiki', :action => 'show', :id => nil }, :param => :project_id,
239 :if => Proc.new { |p| p.wiki && !p.wiki.new_record? }
239 :if => Proc.new { |p| p.wiki && !p.wiki.new_record? }
240 menu.push :boards, { :controller => 'boards', :action => 'index', :id => nil }, :param => :project_id,
240 menu.push :boards, { :controller => 'boards', :action => 'index', :id => nil }, :param => :project_id,
241 :if => Proc.new { |p| p.boards.any? }, :caption => :label_board_plural
241 :if => Proc.new { |p| p.boards.any? }, :caption => :label_board_plural
242 menu.push :files, { :controller => 'files', :action => 'index' }, :caption => :label_file_plural, :param => :project_id
242 menu.push :files, { :controller => 'files', :action => 'index' }, :caption => :label_file_plural, :param => :project_id
243 menu.push :repository, { :controller => 'repositories', :action => 'show', :repository_id => nil, :path => nil, :rev => nil },
243 menu.push :repository, { :controller => 'repositories', :action => 'show', :repository_id => nil, :path => nil, :rev => nil },
244 :if => Proc.new { |p| p.repository && !p.repository.new_record? }
244 :if => Proc.new { |p| p.repository && !p.repository.new_record? }
245 menu.push :settings, { :controller => 'projects', :action => 'settings' }, :last => true
245 menu.push :settings, { :controller => 'projects', :action => 'settings' }, :last => true
246 end
246 end
247
247
248 Redmine::Activity.map do |activity|
248 Redmine::Activity.map do |activity|
249 activity.register :issues, :class_name => %w(Issue Journal)
249 activity.register :issues, :class_name => %w(Issue Journal)
250 activity.register :changesets
250 activity.register :changesets
251 activity.register :news
251 activity.register :news
252 activity.register :documents, :class_name => %w(Document Attachment)
252 activity.register :documents, :class_name => %w(Document Attachment)
253 activity.register :files, :class_name => 'Attachment'
253 activity.register :files, :class_name => 'Attachment'
254 activity.register :wiki_edits, :class_name => 'WikiContent::Version', :default => false
254 activity.register :wiki_edits, :class_name => 'WikiContent::Version', :default => false
255 activity.register :messages, :default => false
255 activity.register :messages, :default => false
256 activity.register :time_entries, :default => false
256 activity.register :time_entries, :default => false
257 end
257 end
258
258
259 Redmine::Search.map do |search|
259 Redmine::Search.map do |search|
260 search.register :issues
260 search.register :issues
261 search.register :news
261 search.register :news
262 search.register :documents
262 search.register :documents
263 search.register :changesets
263 search.register :changesets
264 search.register :wiki_pages
264 search.register :wiki_pages
265 search.register :messages
265 search.register :messages
266 search.register :projects
266 search.register :projects
267 end
267 end
268
268
269 Redmine::WikiFormatting.map do |format|
269 Redmine::WikiFormatting.map do |format|
270 format.register :textile, Redmine::WikiFormatting::Textile::Formatter, Redmine::WikiFormatting::Textile::Helper
270 format.register :textile
271 if Object.const_defined?(:Redcarpet)
271 format.register :markdown if Object.const_defined?(:Redcarpet)
272 format.register :markdown, Redmine::WikiFormatting::Markdown::Formatter, Redmine::WikiFormatting::Markdown::Helper,
273 :label => 'Markdown'
274 end
275 end
272 end
276
273
277 ActionView::Template.register_template_handler :rsb, Redmine::Views::ApiTemplateHandler
274 ActionView::Template.register_template_handler :rsb, Redmine::Views::ApiTemplateHandler
@@ -1,182 +1,198
1 # Redmine - project management software
1 # Redmine - project management software
2 # Copyright (C) 2006-2015 Jean-Philippe Lang
2 # Copyright (C) 2006-2015 Jean-Philippe Lang
3 #
3 #
4 # This program is free software; you can redistribute it and/or
4 # This program is free software; you can redistribute it and/or
5 # modify it under the terms of the GNU General Public License
5 # modify it under the terms of the GNU General Public License
6 # as published by the Free Software Foundation; either version 2
6 # as published by the Free Software Foundation; either version 2
7 # of the License, or (at your option) any later version.
7 # of the License, or (at your option) any later version.
8 #
8 #
9 # This program is distributed in the hope that it will be useful,
9 # This program is distributed in the hope that it will be useful,
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 # GNU General Public License for more details.
12 # GNU General Public License for more details.
13 #
13 #
14 # You should have received a copy of the GNU General Public License
14 # You should have received a copy of the GNU General Public License
15 # along with this program; if not, write to the Free Software
15 # along with this program; if not, write to the Free Software
16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
17
17
18 require 'digest/md5'
18 require 'digest/md5'
19
19
20 module Redmine
20 module Redmine
21 module WikiFormatting
21 module WikiFormatting
22 class StaleSectionError < Exception; end
22 class StaleSectionError < Exception; end
23
23
24 @@formatters = {}
24 @@formatters = {}
25
25
26 class << self
26 class << self
27 def map
27 def map
28 yield self
28 yield self
29 end
29 end
30
30
31 def register(name, formatter, helper, options={})
31 def register(name, *args)
32 options = args.last.is_a?(Hash) ? args.pop : {}
32 name = name.to_s
33 name = name.to_s
33 raise ArgumentError, "format name '#{name}' is already taken" if @@formatters[name]
34 raise ArgumentError, "format name '#{name}' is already taken" if @@formatters[name]
35
36 formatter, helper, parser = args.any? ?
37 args :
38 %w(Formatter Helper HtmlParser).map {|m| "Redmine::WikiFormatting::#{name.classify}::#{m}".constantize}
39
34 @@formatters[name] = {
40 @@formatters[name] = {
35 :formatter => formatter,
41 :formatter => formatter,
36 :helper => helper,
42 :helper => helper,
43 :html_parser => parser,
37 :label => options[:label] || name.humanize
44 :label => options[:label] || name.humanize
38 }
45 }
39 end
46 end
40
47
41 def formatter
48 def formatter
42 formatter_for(Setting.text_formatting)
49 formatter_for(Setting.text_formatting)
43 end
50 end
44
51
52 def html_parser
53 html_parser_for(Setting.text_formatting)
54 end
55
45 def formatter_for(name)
56 def formatter_for(name)
46 entry = @@formatters[name.to_s]
57 entry = @@formatters[name.to_s]
47 (entry && entry[:formatter]) || Redmine::WikiFormatting::NullFormatter::Formatter
58 (entry && entry[:formatter]) || Redmine::WikiFormatting::NullFormatter::Formatter
48 end
59 end
49
60
50 def helper_for(name)
61 def helper_for(name)
51 entry = @@formatters[name.to_s]
62 entry = @@formatters[name.to_s]
52 (entry && entry[:helper]) || Redmine::WikiFormatting::NullFormatter::Helper
63 (entry && entry[:helper]) || Redmine::WikiFormatting::NullFormatter::Helper
53 end
64 end
54
65
66 def html_parser_for(name)
67 entry = @@formatters[name.to_s]
68 (entry && entry[:html_parser]) || Redmine::WikiFormatting::HtmlParser
69 end
70
55 def format_names
71 def format_names
56 @@formatters.keys.map
72 @@formatters.keys.map
57 end
73 end
58
74
59 def formats_for_select
75 def formats_for_select
60 @@formatters.map {|name, options| [options[:label], name]}
76 @@formatters.map {|name, options| [options[:label], name]}
61 end
77 end
62
78
63 def to_html(format, text, options = {})
79 def to_html(format, text, options = {})
64 text = if Setting.cache_formatted_text? && text.size > 2.kilobyte && cache_store && cache_key = cache_key_for(format, text, options[:object], options[:attribute])
80 text = if Setting.cache_formatted_text? && text.size > 2.kilobyte && cache_store && cache_key = cache_key_for(format, text, options[:object], options[:attribute])
65 # Text retrieved from the cache store may be frozen
81 # Text retrieved from the cache store may be frozen
66 # We need to dup it so we can do in-place substitutions with gsub!
82 # We need to dup it so we can do in-place substitutions with gsub!
67 cache_store.fetch cache_key do
83 cache_store.fetch cache_key do
68 formatter_for(format).new(text).to_html
84 formatter_for(format).new(text).to_html
69 end.dup
85 end.dup
70 else
86 else
71 formatter_for(format).new(text).to_html
87 formatter_for(format).new(text).to_html
72 end
88 end
73 text
89 text
74 end
90 end
75
91
76 # Returns true if the text formatter supports single section edit
92 # Returns true if the text formatter supports single section edit
77 def supports_section_edit?
93 def supports_section_edit?
78 (formatter.instance_methods & ['update_section', :update_section]).any?
94 (formatter.instance_methods & ['update_section', :update_section]).any?
79 end
95 end
80
96
81 # Returns a cache key for the given text +format+, +text+, +object+ and +attribute+ or nil if no caching should be done
97 # Returns a cache key for the given text +format+, +text+, +object+ and +attribute+ or nil if no caching should be done
82 def cache_key_for(format, text, object, attribute)
98 def cache_key_for(format, text, object, attribute)
83 if object && attribute && !object.new_record? && format.present?
99 if object && attribute && !object.new_record? && format.present?
84 "formatted_text/#{format}/#{object.class.model_name.cache_key}/#{object.id}-#{attribute}-#{Digest::MD5.hexdigest text}"
100 "formatted_text/#{format}/#{object.class.model_name.cache_key}/#{object.id}-#{attribute}-#{Digest::MD5.hexdigest text}"
85 end
101 end
86 end
102 end
87
103
88 # Returns the cache store used to cache HTML output
104 # Returns the cache store used to cache HTML output
89 def cache_store
105 def cache_store
90 ActionController::Base.cache_store
106 ActionController::Base.cache_store
91 end
107 end
92 end
108 end
93
109
94 module LinksHelper
110 module LinksHelper
95 AUTO_LINK_RE = %r{
111 AUTO_LINK_RE = %r{
96 ( # leading text
112 ( # leading text
97 <\w+[^>]*?>| # leading HTML tag, or
113 <\w+[^>]*?>| # leading HTML tag, or
98 [\s\(\[,;]| # leading punctuation, or
114 [\s\(\[,;]| # leading punctuation, or
99 ^ # beginning of line
115 ^ # beginning of line
100 )
116 )
101 (
117 (
102 (?:https?://)| # protocol spec, or
118 (?:https?://)| # protocol spec, or
103 (?:s?ftps?://)|
119 (?:s?ftps?://)|
104 (?:www\.) # www.*
120 (?:www\.) # www.*
105 )
121 )
106 (
122 (
107 ([^<]\S*?) # url
123 ([^<]\S*?) # url
108 (\/)? # slash
124 (\/)? # slash
109 )
125 )
110 ((?:&gt;)?|[^[:alnum:]_\=\/;\(\)]*?) # post
126 ((?:&gt;)?|[^[:alnum:]_\=\/;\(\)]*?) # post
111 (?=<|\s|$)
127 (?=<|\s|$)
112 }x unless const_defined?(:AUTO_LINK_RE)
128 }x unless const_defined?(:AUTO_LINK_RE)
113
129
114 # Destructively replaces urls into clickable links
130 # Destructively replaces urls into clickable links
115 def auto_link!(text)
131 def auto_link!(text)
116 text.gsub!(AUTO_LINK_RE) do
132 text.gsub!(AUTO_LINK_RE) do
117 all, leading, proto, url, post = $&, $1, $2, $3, $6
133 all, leading, proto, url, post = $&, $1, $2, $3, $6
118 if leading =~ /<a\s/i || leading =~ /![<>=]?/
134 if leading =~ /<a\s/i || leading =~ /![<>=]?/
119 # don't replace URLs that are already linked
135 # don't replace URLs that are already linked
120 # and URLs prefixed with ! !> !< != (textile images)
136 # and URLs prefixed with ! !> !< != (textile images)
121 all
137 all
122 else
138 else
123 # Idea below : an URL with unbalanced parenthesis and
139 # Idea below : an URL with unbalanced parenthesis and
124 # ending by ')' is put into external parenthesis
140 # ending by ')' is put into external parenthesis
125 if ( url[-1]==?) and ((url.count("(") - url.count(")")) < 0 ) )
141 if ( url[-1]==?) and ((url.count("(") - url.count(")")) < 0 ) )
126 url=url[0..-2] # discard closing parenthesis from url
142 url=url[0..-2] # discard closing parenthesis from url
127 post = ")"+post # add closing parenthesis to post
143 post = ")"+post # add closing parenthesis to post
128 end
144 end
129 content = proto + url
145 content = proto + url
130 href = "#{proto=="www."?"http://www.":proto}#{url}"
146 href = "#{proto=="www."?"http://www.":proto}#{url}"
131 %(#{leading}<a class="external" href="#{ERB::Util.html_escape href}">#{ERB::Util.html_escape content}</a>#{post}).html_safe
147 %(#{leading}<a class="external" href="#{ERB::Util.html_escape href}">#{ERB::Util.html_escape content}</a>#{post}).html_safe
132 end
148 end
133 end
149 end
134 end
150 end
135
151
136 # Destructively replaces email addresses into clickable links
152 # Destructively replaces email addresses into clickable links
137 def auto_mailto!(text)
153 def auto_mailto!(text)
138 text.gsub!(/([\w\.!#\$%\-+.\/]+@[A-Za-z0-9\-]+(\.[A-Za-z0-9\-]+)+)/) do
154 text.gsub!(/([\w\.!#\$%\-+.\/]+@[A-Za-z0-9\-]+(\.[A-Za-z0-9\-]+)+)/) do
139 mail = $1
155 mail = $1
140 if text.match(/<a\b[^>]*>(.*)(#{Regexp.escape(mail)})(.*)<\/a>/)
156 if text.match(/<a\b[^>]*>(.*)(#{Regexp.escape(mail)})(.*)<\/a>/)
141 mail
157 mail
142 else
158 else
143 %(<a class="email" href="mailto:#{ERB::Util.html_escape mail}">#{ERB::Util.html_escape mail}</a>).html_safe
159 %(<a class="email" href="mailto:#{ERB::Util.html_escape mail}">#{ERB::Util.html_escape mail}</a>).html_safe
144 end
160 end
145 end
161 end
146 end
162 end
147 end
163 end
148
164
149 # Default formatter module
165 # Default formatter module
150 module NullFormatter
166 module NullFormatter
151 class Formatter
167 class Formatter
152 include ActionView::Helpers::TagHelper
168 include ActionView::Helpers::TagHelper
153 include ActionView::Helpers::TextHelper
169 include ActionView::Helpers::TextHelper
154 include ActionView::Helpers::UrlHelper
170 include ActionView::Helpers::UrlHelper
155 include Redmine::WikiFormatting::LinksHelper
171 include Redmine::WikiFormatting::LinksHelper
156
172
157 def initialize(text)
173 def initialize(text)
158 @text = text
174 @text = text
159 end
175 end
160
176
161 def to_html(*args)
177 def to_html(*args)
162 t = CGI::escapeHTML(@text)
178 t = CGI::escapeHTML(@text)
163 auto_link!(t)
179 auto_link!(t)
164 auto_mailto!(t)
180 auto_mailto!(t)
165 simple_format(t, {}, :sanitize => false)
181 simple_format(t, {}, :sanitize => false)
166 end
182 end
167 end
183 end
168
184
169 module Helper
185 module Helper
170 def wikitoolbar_for(field_id)
186 def wikitoolbar_for(field_id)
171 end
187 end
172
188
173 def heads_for_wiki_formatter
189 def heads_for_wiki_formatter
174 end
190 end
175
191
176 def initial_page_content(page)
192 def initial_page_content(page)
177 page.pretty_title.to_s
193 page.pretty_title.to_s
178 end
194 end
179 end
195 end
180 end
196 end
181 end
197 end
182 end
198 end
@@ -1,22 +1,23
1 x-sender: <jsmith@somenet.foo>
1 x-sender: <jsmith@somenet.foo>
2 x-receiver: <redmine@somenet.foo>
2 x-receiver: <redmine@somenet.foo>
3 Received: from [127.0.0.1] ([127.0.0.1]) by somenet.foo with Quick 'n Easy Mail Server SMTP (1.0.0.0);
3 Received: from [127.0.0.1] ([127.0.0.1]) by somenet.foo with Quick 'n Easy Mail Server SMTP (1.0.0.0);
4 Sun, 14 Dec 2008 16:18:06 GMT
4 Sun, 14 Dec 2008 16:18:06 GMT
5 Message-ID: <494531B9.1070709@somenet.foo>
5 Message-ID: <494531B9.1070709@somenet.foo>
6 Date: Sun, 14 Dec 2008 17:18:01 +0100
6 Date: Sun, 14 Dec 2008 17:18:01 +0100
7 From: "John Smith" <jsmith@somenet.foo>
7 From: "John Smith" <jsmith@somenet.foo>
8 User-Agent: Thunderbird 2.0.0.18 (Windows/20081105)
8 User-Agent: Thunderbird 2.0.0.18 (Windows/20081105)
9 MIME-Version: 1.0
9 MIME-Version: 1.0
10 To: redmine@somenet.foo
10 To: redmine@somenet.foo
11 Subject: HTML email
11 Subject: HTML email
12 Content-Type: text/html; charset=ISO-8859-1
12 Content-Type: text/html; charset=ISO-8859-1
13 Content-Transfer-Encoding: 7bit
13 Content-Transfer-Encoding: 7bit
14
14
15 <!DOCTYPE html PUBLIC "-//W3C//DTD HTML 4.01 Transitional//EN">
15 <!DOCTYPE html PUBLIC "-//W3C//DTD HTML 4.01 Transitional//EN">
16 <html>
16 <html>
17 <head>
17 <head>
18 <style>p {font-size:12.0pt;}</style>
18 </head>
19 </head>
19 <body bgcolor="#ffffff" text="#000000">
20 <body bgcolor="#ffffff" text="#000000">
20 This is a <b>html-only</b> email.<br>
21 This is a <b>html-only</b> email.<br><h1>With a title</h1><p>and a paragraph.</p>
21 </body>
22 </body>
22 </html>
23 </html>
@@ -1,977 +1,997
1 # encoding: utf-8
1 # encoding: utf-8
2 #
2 #
3 # Redmine - project management software
3 # Redmine - project management software
4 # Copyright (C) 2006-2015 Jean-Philippe Lang
4 # Copyright (C) 2006-2015 Jean-Philippe Lang
5 #
5 #
6 # This program is free software; you can redistribute it and/or
6 # This program is free software; you can redistribute it and/or
7 # modify it under the terms of the GNU General Public License
7 # modify it under the terms of the GNU General Public License
8 # as published by the Free Software Foundation; either version 2
8 # as published by the Free Software Foundation; either version 2
9 # of the License, or (at your option) any later version.
9 # of the License, or (at your option) any later version.
10 #
10 #
11 # This program is distributed in the hope that it will be useful,
11 # This program is distributed in the hope that it will be useful,
12 # but WITHOUT ANY WARRANTY; without even the implied warranty of
12 # but WITHOUT ANY WARRANTY; without even the implied warranty of
13 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
14 # GNU General Public License for more details.
14 # GNU General Public License for more details.
15 #
15 #
16 # You should have received a copy of the GNU General Public License
16 # You should have received a copy of the GNU General Public License
17 # along with this program; if not, write to the Free Software
17 # along with this program; if not, write to the Free Software
18 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
18 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
19
19
20 require File.expand_path('../../test_helper', __FILE__)
20 require File.expand_path('../../test_helper', __FILE__)
21
21
22 class MailHandlerTest < ActiveSupport::TestCase
22 class MailHandlerTest < ActiveSupport::TestCase
23 fixtures :users, :projects, :enabled_modules, :roles,
23 fixtures :users, :projects, :enabled_modules, :roles,
24 :members, :member_roles, :users,
24 :members, :member_roles, :users,
25 :email_addresses,
25 :email_addresses,
26 :issues, :issue_statuses,
26 :issues, :issue_statuses,
27 :workflows, :trackers, :projects_trackers,
27 :workflows, :trackers, :projects_trackers,
28 :versions, :enumerations, :issue_categories,
28 :versions, :enumerations, :issue_categories,
29 :custom_fields, :custom_fields_trackers, :custom_fields_projects,
29 :custom_fields, :custom_fields_trackers, :custom_fields_projects,
30 :boards, :messages
30 :boards, :messages
31
31
32 FIXTURES_PATH = File.dirname(__FILE__) + '/../fixtures/mail_handler'
32 FIXTURES_PATH = File.dirname(__FILE__) + '/../fixtures/mail_handler'
33
33
34 def setup
34 def setup
35 ActionMailer::Base.deliveries.clear
35 ActionMailer::Base.deliveries.clear
36 Setting.notified_events = Redmine::Notifiable.all.collect(&:name)
36 Setting.notified_events = Redmine::Notifiable.all.collect(&:name)
37 end
37 end
38
38
39 def teardown
39 def teardown
40 Setting.clear_cache
40 Setting.clear_cache
41 end
41 end
42
42
43 def test_add_issue
43 def test_add_issue
44 ActionMailer::Base.deliveries.clear
44 ActionMailer::Base.deliveries.clear
45 lft1 = new_issue_lft
45 lft1 = new_issue_lft
46 # This email contains: 'Project: onlinestore'
46 # This email contains: 'Project: onlinestore'
47 issue = submit_email('ticket_on_given_project.eml')
47 issue = submit_email('ticket_on_given_project.eml')
48 assert issue.is_a?(Issue)
48 assert issue.is_a?(Issue)
49 assert !issue.new_record?
49 assert !issue.new_record?
50 issue.reload
50 issue.reload
51 assert_equal Project.find(2), issue.project
51 assert_equal Project.find(2), issue.project
52 assert_equal issue.project.trackers.first, issue.tracker
52 assert_equal issue.project.trackers.first, issue.tracker
53 assert_equal 'New ticket on a given project', issue.subject
53 assert_equal 'New ticket on a given project', issue.subject
54 assert_equal User.find_by_login('jsmith'), issue.author
54 assert_equal User.find_by_login('jsmith'), issue.author
55 assert_equal IssueStatus.find_by_name('Resolved'), issue.status
55 assert_equal IssueStatus.find_by_name('Resolved'), issue.status
56 assert issue.description.include?('Lorem ipsum dolor sit amet, consectetuer adipiscing elit.')
56 assert issue.description.include?('Lorem ipsum dolor sit amet, consectetuer adipiscing elit.')
57 assert_equal '2010-01-01', issue.start_date.to_s
57 assert_equal '2010-01-01', issue.start_date.to_s
58 assert_equal '2010-12-31', issue.due_date.to_s
58 assert_equal '2010-12-31', issue.due_date.to_s
59 assert_equal User.find_by_login('jsmith'), issue.assigned_to
59 assert_equal User.find_by_login('jsmith'), issue.assigned_to
60 assert_equal Version.find_by_name('Alpha'), issue.fixed_version
60 assert_equal Version.find_by_name('Alpha'), issue.fixed_version
61 assert_equal 2.5, issue.estimated_hours
61 assert_equal 2.5, issue.estimated_hours
62 assert_equal 30, issue.done_ratio
62 assert_equal 30, issue.done_ratio
63 assert_equal [issue.id, lft1, lft1 + 1], [issue.root_id, issue.lft, issue.rgt]
63 assert_equal [issue.id, lft1, lft1 + 1], [issue.root_id, issue.lft, issue.rgt]
64 # keywords should be removed from the email body
64 # keywords should be removed from the email body
65 assert !issue.description.match(/^Project:/i)
65 assert !issue.description.match(/^Project:/i)
66 assert !issue.description.match(/^Status:/i)
66 assert !issue.description.match(/^Status:/i)
67 assert !issue.description.match(/^Start Date:/i)
67 assert !issue.description.match(/^Start Date:/i)
68 # Email notification should be sent
68 # Email notification should be sent
69 mail = ActionMailer::Base.deliveries.last
69 mail = ActionMailer::Base.deliveries.last
70 assert_not_nil mail
70 assert_not_nil mail
71 assert mail.subject.include?("##{issue.id}")
71 assert mail.subject.include?("##{issue.id}")
72 assert mail.subject.include?('New ticket on a given project')
72 assert mail.subject.include?('New ticket on a given project')
73 end
73 end
74
74
75 def test_add_issue_with_default_tracker
75 def test_add_issue_with_default_tracker
76 # This email contains: 'Project: onlinestore'
76 # This email contains: 'Project: onlinestore'
77 issue = submit_email(
77 issue = submit_email(
78 'ticket_on_given_project.eml',
78 'ticket_on_given_project.eml',
79 :issue => {:tracker => 'Support request'}
79 :issue => {:tracker => 'Support request'}
80 )
80 )
81 assert issue.is_a?(Issue)
81 assert issue.is_a?(Issue)
82 assert !issue.new_record?
82 assert !issue.new_record?
83 issue.reload
83 issue.reload
84 assert_equal 'Support request', issue.tracker.name
84 assert_equal 'Support request', issue.tracker.name
85 end
85 end
86
86
87 def test_add_issue_with_status
87 def test_add_issue_with_status
88 # This email contains: 'Project: onlinestore' and 'Status: Resolved'
88 # This email contains: 'Project: onlinestore' and 'Status: Resolved'
89 issue = submit_email('ticket_on_given_project.eml')
89 issue = submit_email('ticket_on_given_project.eml')
90 assert issue.is_a?(Issue)
90 assert issue.is_a?(Issue)
91 assert !issue.new_record?
91 assert !issue.new_record?
92 issue.reload
92 issue.reload
93 assert_equal Project.find(2), issue.project
93 assert_equal Project.find(2), issue.project
94 assert_equal IssueStatus.find_by_name("Resolved"), issue.status
94 assert_equal IssueStatus.find_by_name("Resolved"), issue.status
95 end
95 end
96
96
97 def test_add_issue_should_accept_is_private_attribute
97 def test_add_issue_should_accept_is_private_attribute
98 issue = submit_email('ticket_on_given_project.eml', :issue => {:is_private => '1'})
98 issue = submit_email('ticket_on_given_project.eml', :issue => {:is_private => '1'})
99 assert issue.is_a?(Issue)
99 assert issue.is_a?(Issue)
100 assert !issue.new_record?
100 assert !issue.new_record?
101 assert_equal true, issue.reload.is_private
101 assert_equal true, issue.reload.is_private
102 end
102 end
103
103
104 def test_add_issue_with_attributes_override
104 def test_add_issue_with_attributes_override
105 issue = submit_email(
105 issue = submit_email(
106 'ticket_with_attributes.eml',
106 'ticket_with_attributes.eml',
107 :allow_override => 'tracker,category,priority'
107 :allow_override => 'tracker,category,priority'
108 )
108 )
109 assert issue.is_a?(Issue)
109 assert issue.is_a?(Issue)
110 assert !issue.new_record?
110 assert !issue.new_record?
111 issue.reload
111 issue.reload
112 assert_equal 'New ticket on a given project', issue.subject
112 assert_equal 'New ticket on a given project', issue.subject
113 assert_equal User.find_by_login('jsmith'), issue.author
113 assert_equal User.find_by_login('jsmith'), issue.author
114 assert_equal Project.find(2), issue.project
114 assert_equal Project.find(2), issue.project
115 assert_equal 'Feature request', issue.tracker.to_s
115 assert_equal 'Feature request', issue.tracker.to_s
116 assert_equal 'Stock management', issue.category.to_s
116 assert_equal 'Stock management', issue.category.to_s
117 assert_equal 'Urgent', issue.priority.to_s
117 assert_equal 'Urgent', issue.priority.to_s
118 assert issue.description.include?('Lorem ipsum dolor sit amet, consectetuer adipiscing elit.')
118 assert issue.description.include?('Lorem ipsum dolor sit amet, consectetuer adipiscing elit.')
119 end
119 end
120
120
121 def test_add_issue_with_group_assignment
121 def test_add_issue_with_group_assignment
122 with_settings :issue_group_assignment => '1' do
122 with_settings :issue_group_assignment => '1' do
123 issue = submit_email('ticket_on_given_project.eml') do |email|
123 issue = submit_email('ticket_on_given_project.eml') do |email|
124 email.gsub!('Assigned to: John Smith', 'Assigned to: B Team')
124 email.gsub!('Assigned to: John Smith', 'Assigned to: B Team')
125 end
125 end
126 assert issue.is_a?(Issue)
126 assert issue.is_a?(Issue)
127 assert !issue.new_record?
127 assert !issue.new_record?
128 issue.reload
128 issue.reload
129 assert_equal Group.find(11), issue.assigned_to
129 assert_equal Group.find(11), issue.assigned_to
130 end
130 end
131 end
131 end
132
132
133 def test_add_issue_with_partial_attributes_override
133 def test_add_issue_with_partial_attributes_override
134 issue = submit_email(
134 issue = submit_email(
135 'ticket_with_attributes.eml',
135 'ticket_with_attributes.eml',
136 :issue => {:priority => 'High'},
136 :issue => {:priority => 'High'},
137 :allow_override => ['tracker']
137 :allow_override => ['tracker']
138 )
138 )
139 assert issue.is_a?(Issue)
139 assert issue.is_a?(Issue)
140 assert !issue.new_record?
140 assert !issue.new_record?
141 issue.reload
141 issue.reload
142 assert_equal 'New ticket on a given project', issue.subject
142 assert_equal 'New ticket on a given project', issue.subject
143 assert_equal User.find_by_login('jsmith'), issue.author
143 assert_equal User.find_by_login('jsmith'), issue.author
144 assert_equal Project.find(2), issue.project
144 assert_equal Project.find(2), issue.project
145 assert_equal 'Feature request', issue.tracker.to_s
145 assert_equal 'Feature request', issue.tracker.to_s
146 assert_nil issue.category
146 assert_nil issue.category
147 assert_equal 'High', issue.priority.to_s
147 assert_equal 'High', issue.priority.to_s
148 assert issue.description.include?('Lorem ipsum dolor sit amet, consectetuer adipiscing elit.')
148 assert issue.description.include?('Lorem ipsum dolor sit amet, consectetuer adipiscing elit.')
149 end
149 end
150
150
151 def test_add_issue_with_spaces_between_attribute_and_separator
151 def test_add_issue_with_spaces_between_attribute_and_separator
152 issue = submit_email(
152 issue = submit_email(
153 'ticket_with_spaces_between_attribute_and_separator.eml',
153 'ticket_with_spaces_between_attribute_and_separator.eml',
154 :allow_override => 'tracker,category,priority'
154 :allow_override => 'tracker,category,priority'
155 )
155 )
156 assert issue.is_a?(Issue)
156 assert issue.is_a?(Issue)
157 assert !issue.new_record?
157 assert !issue.new_record?
158 issue.reload
158 issue.reload
159 assert_equal 'New ticket on a given project', issue.subject
159 assert_equal 'New ticket on a given project', issue.subject
160 assert_equal User.find_by_login('jsmith'), issue.author
160 assert_equal User.find_by_login('jsmith'), issue.author
161 assert_equal Project.find(2), issue.project
161 assert_equal Project.find(2), issue.project
162 assert_equal 'Feature request', issue.tracker.to_s
162 assert_equal 'Feature request', issue.tracker.to_s
163 assert_equal 'Stock management', issue.category.to_s
163 assert_equal 'Stock management', issue.category.to_s
164 assert_equal 'Urgent', issue.priority.to_s
164 assert_equal 'Urgent', issue.priority.to_s
165 assert issue.description.include?('Lorem ipsum dolor sit amet, consectetuer adipiscing elit.')
165 assert issue.description.include?('Lorem ipsum dolor sit amet, consectetuer adipiscing elit.')
166 end
166 end
167
167
168 def test_add_issue_with_attachment_to_specific_project
168 def test_add_issue_with_attachment_to_specific_project
169 issue = submit_email('ticket_with_attachment.eml', :issue => {:project => 'onlinestore'})
169 issue = submit_email('ticket_with_attachment.eml', :issue => {:project => 'onlinestore'})
170 assert issue.is_a?(Issue)
170 assert issue.is_a?(Issue)
171 assert !issue.new_record?
171 assert !issue.new_record?
172 issue.reload
172 issue.reload
173 assert_equal 'Ticket created by email with attachment', issue.subject
173 assert_equal 'Ticket created by email with attachment', issue.subject
174 assert_equal User.find_by_login('jsmith'), issue.author
174 assert_equal User.find_by_login('jsmith'), issue.author
175 assert_equal Project.find(2), issue.project
175 assert_equal Project.find(2), issue.project
176 assert_equal 'This is a new ticket with attachments', issue.description
176 assert_equal 'This is a new ticket with attachments', issue.description
177 # Attachment properties
177 # Attachment properties
178 assert_equal 1, issue.attachments.size
178 assert_equal 1, issue.attachments.size
179 assert_equal 'Paella.jpg', issue.attachments.first.filename
179 assert_equal 'Paella.jpg', issue.attachments.first.filename
180 assert_equal 'image/jpeg', issue.attachments.first.content_type
180 assert_equal 'image/jpeg', issue.attachments.first.content_type
181 assert_equal 10790, issue.attachments.first.filesize
181 assert_equal 10790, issue.attachments.first.filesize
182 end
182 end
183
183
184 def test_add_issue_with_custom_fields
184 def test_add_issue_with_custom_fields
185 issue = submit_email('ticket_with_custom_fields.eml', :issue => {:project => 'onlinestore'})
185 issue = submit_email('ticket_with_custom_fields.eml', :issue => {:project => 'onlinestore'})
186 assert issue.is_a?(Issue)
186 assert issue.is_a?(Issue)
187 assert !issue.new_record?
187 assert !issue.new_record?
188 issue.reload
188 issue.reload
189 assert_equal 'New ticket with custom field values', issue.subject
189 assert_equal 'New ticket with custom field values', issue.subject
190 assert_equal 'PostgreSQL', issue.custom_field_value(1)
190 assert_equal 'PostgreSQL', issue.custom_field_value(1)
191 assert_equal 'Value for a custom field', issue.custom_field_value(2)
191 assert_equal 'Value for a custom field', issue.custom_field_value(2)
192 assert !issue.description.match(/^searchable field:/i)
192 assert !issue.description.match(/^searchable field:/i)
193 end
193 end
194
194
195 def test_add_issue_with_version_custom_fields
195 def test_add_issue_with_version_custom_fields
196 field = IssueCustomField.create!(:name => 'Affected version', :field_format => 'version', :is_for_all => true, :tracker_ids => [1,2,3])
196 field = IssueCustomField.create!(:name => 'Affected version', :field_format => 'version', :is_for_all => true, :tracker_ids => [1,2,3])
197
197
198 issue = submit_email('ticket_with_custom_fields.eml', :issue => {:project => 'ecookbook'}) do |email|
198 issue = submit_email('ticket_with_custom_fields.eml', :issue => {:project => 'ecookbook'}) do |email|
199 email << "Affected version: 1.0\n"
199 email << "Affected version: 1.0\n"
200 end
200 end
201 assert issue.is_a?(Issue)
201 assert issue.is_a?(Issue)
202 assert !issue.new_record?
202 assert !issue.new_record?
203 issue.reload
203 issue.reload
204 assert_equal '2', issue.custom_field_value(field)
204 assert_equal '2', issue.custom_field_value(field)
205 end
205 end
206
206
207 def test_add_issue_should_match_assignee_on_display_name
207 def test_add_issue_should_match_assignee_on_display_name
208 user = User.generate!(:firstname => 'Foo Bar', :lastname => 'Foo Baz')
208 user = User.generate!(:firstname => 'Foo Bar', :lastname => 'Foo Baz')
209 User.add_to_project(user, Project.find(2))
209 User.add_to_project(user, Project.find(2))
210 issue = submit_email('ticket_on_given_project.eml') do |email|
210 issue = submit_email('ticket_on_given_project.eml') do |email|
211 email.sub!(/^Assigned to.*$/, 'Assigned to: Foo Bar Foo baz')
211 email.sub!(/^Assigned to.*$/, 'Assigned to: Foo Bar Foo baz')
212 end
212 end
213 assert issue.is_a?(Issue)
213 assert issue.is_a?(Issue)
214 assert_equal user, issue.assigned_to
214 assert_equal user, issue.assigned_to
215 end
215 end
216
216
217 def test_add_issue_should_set_default_start_date
217 def test_add_issue_should_set_default_start_date
218 with_settings :default_issue_start_date_to_creation_date => '1' do
218 with_settings :default_issue_start_date_to_creation_date => '1' do
219 issue = submit_email('ticket_with_cc.eml', :issue => {:project => 'ecookbook'})
219 issue = submit_email('ticket_with_cc.eml', :issue => {:project => 'ecookbook'})
220 assert issue.is_a?(Issue)
220 assert issue.is_a?(Issue)
221 assert_equal Date.today, issue.start_date
221 assert_equal Date.today, issue.start_date
222 end
222 end
223 end
223 end
224
224
225 def test_add_issue_with_cc
225 def test_add_issue_with_cc
226 issue = submit_email('ticket_with_cc.eml', :issue => {:project => 'ecookbook'})
226 issue = submit_email('ticket_with_cc.eml', :issue => {:project => 'ecookbook'})
227 assert issue.is_a?(Issue)
227 assert issue.is_a?(Issue)
228 assert !issue.new_record?
228 assert !issue.new_record?
229 issue.reload
229 issue.reload
230 assert issue.watched_by?(User.find_by_mail('dlopper@somenet.foo'))
230 assert issue.watched_by?(User.find_by_mail('dlopper@somenet.foo'))
231 assert_equal 1, issue.watcher_user_ids.size
231 assert_equal 1, issue.watcher_user_ids.size
232 end
232 end
233
233
234 def test_add_issue_from_additional_email_address
234 def test_add_issue_from_additional_email_address
235 user = User.find(2)
235 user = User.find(2)
236 user.mail = 'mainaddress@somenet.foo'
236 user.mail = 'mainaddress@somenet.foo'
237 user.save!
237 user.save!
238 EmailAddress.create!(:user => user, :address => 'jsmith@somenet.foo')
238 EmailAddress.create!(:user => user, :address => 'jsmith@somenet.foo')
239
239
240 issue = submit_email('ticket_on_given_project.eml')
240 issue = submit_email('ticket_on_given_project.eml')
241 assert issue
241 assert issue
242 assert_equal user, issue.author
242 assert_equal user, issue.author
243 end
243 end
244
244
245 def test_add_issue_by_unknown_user
245 def test_add_issue_by_unknown_user
246 assert_no_difference 'User.count' do
246 assert_no_difference 'User.count' do
247 assert_equal false,
247 assert_equal false,
248 submit_email(
248 submit_email(
249 'ticket_by_unknown_user.eml',
249 'ticket_by_unknown_user.eml',
250 :issue => {:project => 'ecookbook'}
250 :issue => {:project => 'ecookbook'}
251 )
251 )
252 end
252 end
253 end
253 end
254
254
255 def test_add_issue_by_anonymous_user
255 def test_add_issue_by_anonymous_user
256 Role.anonymous.add_permission!(:add_issues)
256 Role.anonymous.add_permission!(:add_issues)
257 assert_no_difference 'User.count' do
257 assert_no_difference 'User.count' do
258 issue = submit_email(
258 issue = submit_email(
259 'ticket_by_unknown_user.eml',
259 'ticket_by_unknown_user.eml',
260 :issue => {:project => 'ecookbook'},
260 :issue => {:project => 'ecookbook'},
261 :unknown_user => 'accept'
261 :unknown_user => 'accept'
262 )
262 )
263 assert issue.is_a?(Issue)
263 assert issue.is_a?(Issue)
264 assert issue.author.anonymous?
264 assert issue.author.anonymous?
265 end
265 end
266 end
266 end
267
267
268 def test_add_issue_by_anonymous_user_with_no_from_address
268 def test_add_issue_by_anonymous_user_with_no_from_address
269 Role.anonymous.add_permission!(:add_issues)
269 Role.anonymous.add_permission!(:add_issues)
270 assert_no_difference 'User.count' do
270 assert_no_difference 'User.count' do
271 issue = submit_email(
271 issue = submit_email(
272 'ticket_by_empty_user.eml',
272 'ticket_by_empty_user.eml',
273 :issue => {:project => 'ecookbook'},
273 :issue => {:project => 'ecookbook'},
274 :unknown_user => 'accept'
274 :unknown_user => 'accept'
275 )
275 )
276 assert issue.is_a?(Issue)
276 assert issue.is_a?(Issue)
277 assert issue.author.anonymous?
277 assert issue.author.anonymous?
278 end
278 end
279 end
279 end
280
280
281 def test_add_issue_by_anonymous_user_on_private_project
281 def test_add_issue_by_anonymous_user_on_private_project
282 Role.anonymous.add_permission!(:add_issues)
282 Role.anonymous.add_permission!(:add_issues)
283 assert_no_difference 'User.count' do
283 assert_no_difference 'User.count' do
284 assert_no_difference 'Issue.count' do
284 assert_no_difference 'Issue.count' do
285 assert_equal false,
285 assert_equal false,
286 submit_email(
286 submit_email(
287 'ticket_by_unknown_user.eml',
287 'ticket_by_unknown_user.eml',
288 :issue => {:project => 'onlinestore'},
288 :issue => {:project => 'onlinestore'},
289 :unknown_user => 'accept'
289 :unknown_user => 'accept'
290 )
290 )
291 end
291 end
292 end
292 end
293 end
293 end
294
294
295 def test_add_issue_by_anonymous_user_on_private_project_without_permission_check
295 def test_add_issue_by_anonymous_user_on_private_project_without_permission_check
296 lft1 = new_issue_lft
296 lft1 = new_issue_lft
297 assert_no_difference 'User.count' do
297 assert_no_difference 'User.count' do
298 assert_difference 'Issue.count' do
298 assert_difference 'Issue.count' do
299 issue = submit_email(
299 issue = submit_email(
300 'ticket_by_unknown_user.eml',
300 'ticket_by_unknown_user.eml',
301 :issue => {:project => 'onlinestore'},
301 :issue => {:project => 'onlinestore'},
302 :no_permission_check => '1',
302 :no_permission_check => '1',
303 :unknown_user => 'accept'
303 :unknown_user => 'accept'
304 )
304 )
305 assert issue.is_a?(Issue)
305 assert issue.is_a?(Issue)
306 assert issue.author.anonymous?
306 assert issue.author.anonymous?
307 assert !issue.project.is_public?
307 assert !issue.project.is_public?
308 assert_equal [issue.id, lft1, lft1 + 1], [issue.root_id, issue.lft, issue.rgt]
308 assert_equal [issue.id, lft1, lft1 + 1], [issue.root_id, issue.lft, issue.rgt]
309 end
309 end
310 end
310 end
311 end
311 end
312
312
313 def test_add_issue_by_created_user
313 def test_add_issue_by_created_user
314 Setting.default_language = 'en'
314 Setting.default_language = 'en'
315 assert_difference 'User.count' do
315 assert_difference 'User.count' do
316 issue = submit_email(
316 issue = submit_email(
317 'ticket_by_unknown_user.eml',
317 'ticket_by_unknown_user.eml',
318 :issue => {:project => 'ecookbook'},
318 :issue => {:project => 'ecookbook'},
319 :unknown_user => 'create'
319 :unknown_user => 'create'
320 )
320 )
321 assert issue.is_a?(Issue)
321 assert issue.is_a?(Issue)
322 assert issue.author.active?
322 assert issue.author.active?
323 assert_equal 'john.doe@somenet.foo', issue.author.mail
323 assert_equal 'john.doe@somenet.foo', issue.author.mail
324 assert_equal 'John', issue.author.firstname
324 assert_equal 'John', issue.author.firstname
325 assert_equal 'Doe', issue.author.lastname
325 assert_equal 'Doe', issue.author.lastname
326
326
327 # account information
327 # account information
328 email = ActionMailer::Base.deliveries.first
328 email = ActionMailer::Base.deliveries.first
329 assert_not_nil email
329 assert_not_nil email
330 assert email.subject.include?('account activation')
330 assert email.subject.include?('account activation')
331 login = mail_body(email).match(/\* Login: (.*)$/)[1].strip
331 login = mail_body(email).match(/\* Login: (.*)$/)[1].strip
332 password = mail_body(email).match(/\* Password: (.*)$/)[1].strip
332 password = mail_body(email).match(/\* Password: (.*)$/)[1].strip
333 assert_equal issue.author, User.try_to_login(login, password)
333 assert_equal issue.author, User.try_to_login(login, password)
334 end
334 end
335 end
335 end
336
336
337 def test_created_user_should_be_added_to_groups
337 def test_created_user_should_be_added_to_groups
338 group1 = Group.generate!
338 group1 = Group.generate!
339 group2 = Group.generate!
339 group2 = Group.generate!
340
340
341 assert_difference 'User.count' do
341 assert_difference 'User.count' do
342 submit_email(
342 submit_email(
343 'ticket_by_unknown_user.eml',
343 'ticket_by_unknown_user.eml',
344 :issue => {:project => 'ecookbook'},
344 :issue => {:project => 'ecookbook'},
345 :unknown_user => 'create',
345 :unknown_user => 'create',
346 :default_group => "#{group1.name},#{group2.name}"
346 :default_group => "#{group1.name},#{group2.name}"
347 )
347 )
348 end
348 end
349 user = User.order('id DESC').first
349 user = User.order('id DESC').first
350 assert_equal [group1, group2].sort, user.groups.sort
350 assert_equal [group1, group2].sort, user.groups.sort
351 end
351 end
352
352
353 def test_created_user_should_not_receive_account_information_with_no_account_info_option
353 def test_created_user_should_not_receive_account_information_with_no_account_info_option
354 assert_difference 'User.count' do
354 assert_difference 'User.count' do
355 submit_email(
355 submit_email(
356 'ticket_by_unknown_user.eml',
356 'ticket_by_unknown_user.eml',
357 :issue => {:project => 'ecookbook'},
357 :issue => {:project => 'ecookbook'},
358 :unknown_user => 'create',
358 :unknown_user => 'create',
359 :no_account_notice => '1'
359 :no_account_notice => '1'
360 )
360 )
361 end
361 end
362
362
363 # only 1 email for the new issue notification
363 # only 1 email for the new issue notification
364 assert_equal 1, ActionMailer::Base.deliveries.size
364 assert_equal 1, ActionMailer::Base.deliveries.size
365 email = ActionMailer::Base.deliveries.first
365 email = ActionMailer::Base.deliveries.first
366 assert_include 'Ticket by unknown user', email.subject
366 assert_include 'Ticket by unknown user', email.subject
367 end
367 end
368
368
369 def test_created_user_should_have_mail_notification_to_none_with_no_notification_option
369 def test_created_user_should_have_mail_notification_to_none_with_no_notification_option
370 assert_difference 'User.count' do
370 assert_difference 'User.count' do
371 submit_email(
371 submit_email(
372 'ticket_by_unknown_user.eml',
372 'ticket_by_unknown_user.eml',
373 :issue => {:project => 'ecookbook'},
373 :issue => {:project => 'ecookbook'},
374 :unknown_user => 'create',
374 :unknown_user => 'create',
375 :no_notification => '1'
375 :no_notification => '1'
376 )
376 )
377 end
377 end
378 user = User.order('id DESC').first
378 user = User.order('id DESC').first
379 assert_equal 'none', user.mail_notification
379 assert_equal 'none', user.mail_notification
380 end
380 end
381
381
382 def test_add_issue_without_from_header
382 def test_add_issue_without_from_header
383 Role.anonymous.add_permission!(:add_issues)
383 Role.anonymous.add_permission!(:add_issues)
384 assert_equal false, submit_email('ticket_without_from_header.eml')
384 assert_equal false, submit_email('ticket_without_from_header.eml')
385 end
385 end
386
386
387 def test_add_issue_with_invalid_attributes
387 def test_add_issue_with_invalid_attributes
388 with_settings :default_issue_start_date_to_creation_date => '0' do
388 with_settings :default_issue_start_date_to_creation_date => '0' do
389 issue = submit_email(
389 issue = submit_email(
390 'ticket_with_invalid_attributes.eml',
390 'ticket_with_invalid_attributes.eml',
391 :allow_override => 'tracker,category,priority'
391 :allow_override => 'tracker,category,priority'
392 )
392 )
393 assert issue.is_a?(Issue)
393 assert issue.is_a?(Issue)
394 assert !issue.new_record?
394 assert !issue.new_record?
395 issue.reload
395 issue.reload
396 assert_nil issue.assigned_to
396 assert_nil issue.assigned_to
397 assert_nil issue.start_date
397 assert_nil issue.start_date
398 assert_nil issue.due_date
398 assert_nil issue.due_date
399 assert_equal 0, issue.done_ratio
399 assert_equal 0, issue.done_ratio
400 assert_equal 'Normal', issue.priority.to_s
400 assert_equal 'Normal', issue.priority.to_s
401 assert issue.description.include?('Lorem ipsum dolor sit amet, consectetuer adipiscing elit.')
401 assert issue.description.include?('Lorem ipsum dolor sit amet, consectetuer adipiscing elit.')
402 end
402 end
403 end
403 end
404
404
405 def test_add_issue_with_invalid_project_should_be_assigned_to_default_project
405 def test_add_issue_with_invalid_project_should_be_assigned_to_default_project
406 issue = submit_email('ticket_on_given_project.eml', :issue => {:project => 'ecookbook'}, :allow_override => 'project') do |email|
406 issue = submit_email('ticket_on_given_project.eml', :issue => {:project => 'ecookbook'}, :allow_override => 'project') do |email|
407 email.gsub!(/^Project:.+$/, 'Project: invalid')
407 email.gsub!(/^Project:.+$/, 'Project: invalid')
408 end
408 end
409 assert issue.is_a?(Issue)
409 assert issue.is_a?(Issue)
410 assert !issue.new_record?
410 assert !issue.new_record?
411 assert_equal 'ecookbook', issue.project.identifier
411 assert_equal 'ecookbook', issue.project.identifier
412 end
412 end
413
413
414 def test_add_issue_with_localized_attributes
414 def test_add_issue_with_localized_attributes
415 User.find_by_mail('jsmith@somenet.foo').update_attribute 'language', 'fr'
415 User.find_by_mail('jsmith@somenet.foo').update_attribute 'language', 'fr'
416 issue = submit_email(
416 issue = submit_email(
417 'ticket_with_localized_attributes.eml',
417 'ticket_with_localized_attributes.eml',
418 :allow_override => 'tracker,category,priority'
418 :allow_override => 'tracker,category,priority'
419 )
419 )
420 assert issue.is_a?(Issue)
420 assert issue.is_a?(Issue)
421 assert !issue.new_record?
421 assert !issue.new_record?
422 issue.reload
422 issue.reload
423 assert_equal 'New ticket on a given project', issue.subject
423 assert_equal 'New ticket on a given project', issue.subject
424 assert_equal User.find_by_login('jsmith'), issue.author
424 assert_equal User.find_by_login('jsmith'), issue.author
425 assert_equal Project.find(2), issue.project
425 assert_equal Project.find(2), issue.project
426 assert_equal 'Feature request', issue.tracker.to_s
426 assert_equal 'Feature request', issue.tracker.to_s
427 assert_equal 'Stock management', issue.category.to_s
427 assert_equal 'Stock management', issue.category.to_s
428 assert_equal 'Urgent', issue.priority.to_s
428 assert_equal 'Urgent', issue.priority.to_s
429 assert issue.description.include?('Lorem ipsum dolor sit amet, consectetuer adipiscing elit.')
429 assert issue.description.include?('Lorem ipsum dolor sit amet, consectetuer adipiscing elit.')
430 end
430 end
431
431
432 def test_add_issue_with_japanese_keywords
432 def test_add_issue_with_japanese_keywords
433 ja_dev = "\xe9\x96\x8b\xe7\x99\xba".force_encoding('UTF-8')
433 ja_dev = "\xe9\x96\x8b\xe7\x99\xba".force_encoding('UTF-8')
434 tracker = Tracker.generate!(:name => ja_dev)
434 tracker = Tracker.generate!(:name => ja_dev)
435 Project.find(1).trackers << tracker
435 Project.find(1).trackers << tracker
436 issue = submit_email(
436 issue = submit_email(
437 'japanese_keywords_iso_2022_jp.eml',
437 'japanese_keywords_iso_2022_jp.eml',
438 :issue => {:project => 'ecookbook'},
438 :issue => {:project => 'ecookbook'},
439 :allow_override => 'tracker'
439 :allow_override => 'tracker'
440 )
440 )
441 assert_kind_of Issue, issue
441 assert_kind_of Issue, issue
442 assert_equal tracker, issue.tracker
442 assert_equal tracker, issue.tracker
443 end
443 end
444
444
445 def test_add_issue_from_apple_mail
445 def test_add_issue_from_apple_mail
446 issue = submit_email(
446 issue = submit_email(
447 'apple_mail_with_attachment.eml',
447 'apple_mail_with_attachment.eml',
448 :issue => {:project => 'ecookbook'}
448 :issue => {:project => 'ecookbook'}
449 )
449 )
450 assert_kind_of Issue, issue
450 assert_kind_of Issue, issue
451 assert_equal 1, issue.attachments.size
451 assert_equal 1, issue.attachments.size
452
452
453 attachment = issue.attachments.first
453 attachment = issue.attachments.first
454 assert_equal 'paella.jpg', attachment.filename
454 assert_equal 'paella.jpg', attachment.filename
455 assert_equal 10790, attachment.filesize
455 assert_equal 10790, attachment.filesize
456 assert File.exist?(attachment.diskfile)
456 assert File.exist?(attachment.diskfile)
457 assert_equal 10790, File.size(attachment.diskfile)
457 assert_equal 10790, File.size(attachment.diskfile)
458 assert_equal 'caaf384198bcbc9563ab5c058acd73cd', attachment.digest
458 assert_equal 'caaf384198bcbc9563ab5c058acd73cd', attachment.digest
459 end
459 end
460
460
461 def test_thunderbird_with_attachment_ja
461 def test_thunderbird_with_attachment_ja
462 issue = submit_email(
462 issue = submit_email(
463 'thunderbird_with_attachment_ja.eml',
463 'thunderbird_with_attachment_ja.eml',
464 :issue => {:project => 'ecookbook'}
464 :issue => {:project => 'ecookbook'}
465 )
465 )
466 assert_kind_of Issue, issue
466 assert_kind_of Issue, issue
467 assert_equal 1, issue.attachments.size
467 assert_equal 1, issue.attachments.size
468 ja = "\xe3\x83\x86\xe3\x82\xb9\xe3\x83\x88.txt".force_encoding('UTF-8')
468 ja = "\xe3\x83\x86\xe3\x82\xb9\xe3\x83\x88.txt".force_encoding('UTF-8')
469 attachment = issue.attachments.first
469 attachment = issue.attachments.first
470 assert_equal ja, attachment.filename
470 assert_equal ja, attachment.filename
471 assert_equal 5, attachment.filesize
471 assert_equal 5, attachment.filesize
472 assert File.exist?(attachment.diskfile)
472 assert File.exist?(attachment.diskfile)
473 assert_equal 5, File.size(attachment.diskfile)
473 assert_equal 5, File.size(attachment.diskfile)
474 assert_equal 'd8e8fca2dc0f896fd7cb4cb0031ba249', attachment.digest
474 assert_equal 'd8e8fca2dc0f896fd7cb4cb0031ba249', attachment.digest
475 end
475 end
476
476
477 def test_gmail_with_attachment_ja
477 def test_gmail_with_attachment_ja
478 issue = submit_email(
478 issue = submit_email(
479 'gmail_with_attachment_ja.eml',
479 'gmail_with_attachment_ja.eml',
480 :issue => {:project => 'ecookbook'}
480 :issue => {:project => 'ecookbook'}
481 )
481 )
482 assert_kind_of Issue, issue
482 assert_kind_of Issue, issue
483 assert_equal 1, issue.attachments.size
483 assert_equal 1, issue.attachments.size
484 ja = "\xe3\x83\x86\xe3\x82\xb9\xe3\x83\x88.txt".force_encoding('UTF-8')
484 ja = "\xe3\x83\x86\xe3\x82\xb9\xe3\x83\x88.txt".force_encoding('UTF-8')
485 attachment = issue.attachments.first
485 attachment = issue.attachments.first
486 assert_equal ja, attachment.filename
486 assert_equal ja, attachment.filename
487 assert_equal 5, attachment.filesize
487 assert_equal 5, attachment.filesize
488 assert File.exist?(attachment.diskfile)
488 assert File.exist?(attachment.diskfile)
489 assert_equal 5, File.size(attachment.diskfile)
489 assert_equal 5, File.size(attachment.diskfile)
490 assert_equal 'd8e8fca2dc0f896fd7cb4cb0031ba249', attachment.digest
490 assert_equal 'd8e8fca2dc0f896fd7cb4cb0031ba249', attachment.digest
491 end
491 end
492
492
493 def test_thunderbird_with_attachment_latin1
493 def test_thunderbird_with_attachment_latin1
494 issue = submit_email(
494 issue = submit_email(
495 'thunderbird_with_attachment_iso-8859-1.eml',
495 'thunderbird_with_attachment_iso-8859-1.eml',
496 :issue => {:project => 'ecookbook'}
496 :issue => {:project => 'ecookbook'}
497 )
497 )
498 assert_kind_of Issue, issue
498 assert_kind_of Issue, issue
499 assert_equal 1, issue.attachments.size
499 assert_equal 1, issue.attachments.size
500 u = "".force_encoding('UTF-8')
500 u = "".force_encoding('UTF-8')
501 u1 = "\xc3\x84\xc3\xa4\xc3\x96\xc3\xb6\xc3\x9c\xc3\xbc".force_encoding('UTF-8')
501 u1 = "\xc3\x84\xc3\xa4\xc3\x96\xc3\xb6\xc3\x9c\xc3\xbc".force_encoding('UTF-8')
502 11.times { u << u1 }
502 11.times { u << u1 }
503 attachment = issue.attachments.first
503 attachment = issue.attachments.first
504 assert_equal "#{u}.png", attachment.filename
504 assert_equal "#{u}.png", attachment.filename
505 assert_equal 130, attachment.filesize
505 assert_equal 130, attachment.filesize
506 assert File.exist?(attachment.diskfile)
506 assert File.exist?(attachment.diskfile)
507 assert_equal 130, File.size(attachment.diskfile)
507 assert_equal 130, File.size(attachment.diskfile)
508 assert_equal '4d80e667ac37dddfe05502530f152abb', attachment.digest
508 assert_equal '4d80e667ac37dddfe05502530f152abb', attachment.digest
509 end
509 end
510
510
511 def test_gmail_with_attachment_latin1
511 def test_gmail_with_attachment_latin1
512 issue = submit_email(
512 issue = submit_email(
513 'gmail_with_attachment_iso-8859-1.eml',
513 'gmail_with_attachment_iso-8859-1.eml',
514 :issue => {:project => 'ecookbook'}
514 :issue => {:project => 'ecookbook'}
515 )
515 )
516 assert_kind_of Issue, issue
516 assert_kind_of Issue, issue
517 assert_equal 1, issue.attachments.size
517 assert_equal 1, issue.attachments.size
518 u = "".force_encoding('UTF-8')
518 u = "".force_encoding('UTF-8')
519 u1 = "\xc3\x84\xc3\xa4\xc3\x96\xc3\xb6\xc3\x9c\xc3\xbc".force_encoding('UTF-8')
519 u1 = "\xc3\x84\xc3\xa4\xc3\x96\xc3\xb6\xc3\x9c\xc3\xbc".force_encoding('UTF-8')
520 11.times { u << u1 }
520 11.times { u << u1 }
521 attachment = issue.attachments.first
521 attachment = issue.attachments.first
522 assert_equal "#{u}.txt", attachment.filename
522 assert_equal "#{u}.txt", attachment.filename
523 assert_equal 5, attachment.filesize
523 assert_equal 5, attachment.filesize
524 assert File.exist?(attachment.diskfile)
524 assert File.exist?(attachment.diskfile)
525 assert_equal 5, File.size(attachment.diskfile)
525 assert_equal 5, File.size(attachment.diskfile)
526 assert_equal 'd8e8fca2dc0f896fd7cb4cb0031ba249', attachment.digest
526 assert_equal 'd8e8fca2dc0f896fd7cb4cb0031ba249', attachment.digest
527 end
527 end
528
528
529 def test_multiple_inline_text_parts_should_be_appended_to_issue_description
529 def test_multiple_inline_text_parts_should_be_appended_to_issue_description
530 issue = submit_email('multiple_text_parts.eml', :issue => {:project => 'ecookbook'})
530 issue = submit_email('multiple_text_parts.eml', :issue => {:project => 'ecookbook'})
531 assert_include 'first', issue.description
531 assert_include 'first', issue.description
532 assert_include 'second', issue.description
532 assert_include 'second', issue.description
533 assert_include 'third', issue.description
533 assert_include 'third', issue.description
534 end
534 end
535
535
536 def test_attachment_text_part_should_be_added_as_issue_attachment
536 def test_attachment_text_part_should_be_added_as_issue_attachment
537 issue = submit_email('multiple_text_parts.eml', :issue => {:project => 'ecookbook'})
537 issue = submit_email('multiple_text_parts.eml', :issue => {:project => 'ecookbook'})
538 assert_not_include 'Plain text attachment', issue.description
538 assert_not_include 'Plain text attachment', issue.description
539 attachment = issue.attachments.detect {|a| a.filename == 'textfile.txt'}
539 attachment = issue.attachments.detect {|a| a.filename == 'textfile.txt'}
540 assert_not_nil attachment
540 assert_not_nil attachment
541 assert_include 'Plain text attachment', File.read(attachment.diskfile)
541 assert_include 'Plain text attachment', File.read(attachment.diskfile)
542 end
542 end
543
543
544 def test_add_issue_with_iso_8859_1_subject
544 def test_add_issue_with_iso_8859_1_subject
545 issue = submit_email(
545 issue = submit_email(
546 'subject_as_iso-8859-1.eml',
546 'subject_as_iso-8859-1.eml',
547 :issue => {:project => 'ecookbook'}
547 :issue => {:project => 'ecookbook'}
548 )
548 )
549 str = "Testmail from Webmail: \xc3\xa4 \xc3\xb6 \xc3\xbc...".force_encoding('UTF-8')
549 str = "Testmail from Webmail: \xc3\xa4 \xc3\xb6 \xc3\xbc...".force_encoding('UTF-8')
550 assert_kind_of Issue, issue
550 assert_kind_of Issue, issue
551 assert_equal str, issue.subject
551 assert_equal str, issue.subject
552 end
552 end
553
553
554 def test_quoted_printable_utf8
554 def test_quoted_printable_utf8
555 issue = submit_email(
555 issue = submit_email(
556 'quoted_printable_utf8.eml',
556 'quoted_printable_utf8.eml',
557 :issue => {:project => 'ecookbook'}
557 :issue => {:project => 'ecookbook'}
558 )
558 )
559 assert_kind_of Issue, issue
559 assert_kind_of Issue, issue
560 str = "Freundliche Gr\xc3\xbcsse".force_encoding('UTF-8')
560 str = "Freundliche Gr\xc3\xbcsse".force_encoding('UTF-8')
561 assert_equal str, issue.description
561 assert_equal str, issue.description
562 end
562 end
563
563
564 def test_gmail_iso8859_2
564 def test_gmail_iso8859_2
565 issue = submit_email(
565 issue = submit_email(
566 'gmail-iso8859-2.eml',
566 'gmail-iso8859-2.eml',
567 :issue => {:project => 'ecookbook'}
567 :issue => {:project => 'ecookbook'}
568 )
568 )
569 assert_kind_of Issue, issue
569 assert_kind_of Issue, issue
570 str = "Na \xc5\xa1triku se su\xc5\xa1i \xc5\xa1osi\xc4\x87.".force_encoding('UTF-8')
570 str = "Na \xc5\xa1triku se su\xc5\xa1i \xc5\xa1osi\xc4\x87.".force_encoding('UTF-8')
571 assert issue.description.include?(str)
571 assert issue.description.include?(str)
572 end
572 end
573
573
574 def test_add_issue_with_japanese_subject
574 def test_add_issue_with_japanese_subject
575 issue = submit_email(
575 issue = submit_email(
576 'subject_japanese_1.eml',
576 'subject_japanese_1.eml',
577 :issue => {:project => 'ecookbook'}
577 :issue => {:project => 'ecookbook'}
578 )
578 )
579 assert_kind_of Issue, issue
579 assert_kind_of Issue, issue
580 ja = "\xe3\x83\x86\xe3\x82\xb9\xe3\x83\x88".force_encoding('UTF-8')
580 ja = "\xe3\x83\x86\xe3\x82\xb9\xe3\x83\x88".force_encoding('UTF-8')
581 assert_equal ja, issue.subject
581 assert_equal ja, issue.subject
582 end
582 end
583
583
584 def test_add_issue_with_korean_body
584 def test_add_issue_with_korean_body
585 # Make sure mail bodies with a charset unknown to Ruby
585 # Make sure mail bodies with a charset unknown to Ruby
586 # but known to the Mail gem 2.5.4 are handled correctly
586 # but known to the Mail gem 2.5.4 are handled correctly
587 kr = "\xEA\xB3\xA0\xEB\xA7\x99\xEC\x8A\xB5\xEB\x8B\x88\xEB\x8B\xA4.".force_encoding('UTF-8')
587 kr = "\xEA\xB3\xA0\xEB\xA7\x99\xEC\x8A\xB5\xEB\x8B\x88\xEB\x8B\xA4.".force_encoding('UTF-8')
588 issue = submit_email(
588 issue = submit_email(
589 'body_ks_c_5601-1987.eml',
589 'body_ks_c_5601-1987.eml',
590 :issue => {:project => 'ecookbook'}
590 :issue => {:project => 'ecookbook'}
591 )
591 )
592 assert_kind_of Issue, issue
592 assert_kind_of Issue, issue
593 assert_equal kr, issue.description
593 assert_equal kr, issue.description
594 end
594 end
595
595
596 def test_add_issue_with_no_subject_header
596 def test_add_issue_with_no_subject_header
597 issue = submit_email(
597 issue = submit_email(
598 'no_subject_header.eml',
598 'no_subject_header.eml',
599 :issue => {:project => 'ecookbook'}
599 :issue => {:project => 'ecookbook'}
600 )
600 )
601 assert_kind_of Issue, issue
601 assert_kind_of Issue, issue
602 assert_equal '(no subject)', issue.subject
602 assert_equal '(no subject)', issue.subject
603 end
603 end
604
604
605 def test_add_issue_with_mixed_japanese_subject
605 def test_add_issue_with_mixed_japanese_subject
606 issue = submit_email(
606 issue = submit_email(
607 'subject_japanese_2.eml',
607 'subject_japanese_2.eml',
608 :issue => {:project => 'ecookbook'}
608 :issue => {:project => 'ecookbook'}
609 )
609 )
610 assert_kind_of Issue, issue
610 assert_kind_of Issue, issue
611 ja = "Re: \xe3\x83\x86\xe3\x82\xb9\xe3\x83\x88".force_encoding('UTF-8')
611 ja = "Re: \xe3\x83\x86\xe3\x82\xb9\xe3\x83\x88".force_encoding('UTF-8')
612 assert_equal ja, issue.subject
612 assert_equal ja, issue.subject
613 end
613 end
614
614
615 def test_should_ignore_emails_from_locked_users
615 def test_should_ignore_emails_from_locked_users
616 User.find(2).lock!
616 User.find(2).lock!
617
617
618 MailHandler.any_instance.expects(:dispatch).never
618 MailHandler.any_instance.expects(:dispatch).never
619 assert_no_difference 'Issue.count' do
619 assert_no_difference 'Issue.count' do
620 assert_equal false, submit_email('ticket_on_given_project.eml')
620 assert_equal false, submit_email('ticket_on_given_project.eml')
621 end
621 end
622 end
622 end
623
623
624 def test_should_ignore_emails_from_emission_address
624 def test_should_ignore_emails_from_emission_address
625 Role.anonymous.add_permission!(:add_issues)
625 Role.anonymous.add_permission!(:add_issues)
626 assert_no_difference 'User.count' do
626 assert_no_difference 'User.count' do
627 assert_equal false,
627 assert_equal false,
628 submit_email(
628 submit_email(
629 'ticket_from_emission_address.eml',
629 'ticket_from_emission_address.eml',
630 :issue => {:project => 'ecookbook'},
630 :issue => {:project => 'ecookbook'},
631 :unknown_user => 'create'
631 :unknown_user => 'create'
632 )
632 )
633 end
633 end
634 end
634 end
635
635
636 def test_should_ignore_auto_replied_emails
636 def test_should_ignore_auto_replied_emails
637 MailHandler.any_instance.expects(:dispatch).never
637 MailHandler.any_instance.expects(:dispatch).never
638 [
638 [
639 "Auto-Submitted: auto-replied",
639 "Auto-Submitted: auto-replied",
640 "Auto-Submitted: Auto-Replied",
640 "Auto-Submitted: Auto-Replied",
641 "Auto-Submitted: auto-generated",
641 "Auto-Submitted: auto-generated",
642 'X-Autoreply: yes'
642 'X-Autoreply: yes'
643 ].each do |header|
643 ].each do |header|
644 raw = IO.read(File.join(FIXTURES_PATH, 'ticket_on_given_project.eml'))
644 raw = IO.read(File.join(FIXTURES_PATH, 'ticket_on_given_project.eml'))
645 raw = header + "\n" + raw
645 raw = header + "\n" + raw
646
646
647 assert_no_difference 'Issue.count' do
647 assert_no_difference 'Issue.count' do
648 assert_equal false, MailHandler.receive(raw), "email with #{header} header was not ignored"
648 assert_equal false, MailHandler.receive(raw), "email with #{header} header was not ignored"
649 end
649 end
650 end
650 end
651 end
651 end
652
652
653 test "should not ignore Auto-Submitted headers not defined in RFC3834" do
653 test "should not ignore Auto-Submitted headers not defined in RFC3834" do
654 [
654 [
655 "Auto-Submitted: auto-forwarded"
655 "Auto-Submitted: auto-forwarded"
656 ].each do |header|
656 ].each do |header|
657 raw = IO.read(File.join(FIXTURES_PATH, 'ticket_on_given_project.eml'))
657 raw = IO.read(File.join(FIXTURES_PATH, 'ticket_on_given_project.eml'))
658 raw = header + "\n" + raw
658 raw = header + "\n" + raw
659
659
660 assert_difference 'Issue.count', 1 do
660 assert_difference 'Issue.count', 1 do
661 assert_not_nil MailHandler.receive(raw), "email with #{header} header was ignored"
661 assert_not_nil MailHandler.receive(raw), "email with #{header} header was ignored"
662 end
662 end
663 end
663 end
664 end
664 end
665
665
666 def test_add_issue_should_send_email_notification
666 def test_add_issue_should_send_email_notification
667 Setting.notified_events = ['issue_added']
667 Setting.notified_events = ['issue_added']
668 ActionMailer::Base.deliveries.clear
668 ActionMailer::Base.deliveries.clear
669 # This email contains: 'Project: onlinestore'
669 # This email contains: 'Project: onlinestore'
670 issue = submit_email('ticket_on_given_project.eml')
670 issue = submit_email('ticket_on_given_project.eml')
671 assert issue.is_a?(Issue)
671 assert issue.is_a?(Issue)
672 assert_equal 1, ActionMailer::Base.deliveries.size
672 assert_equal 1, ActionMailer::Base.deliveries.size
673 end
673 end
674
674
675 def test_update_issue
675 def test_update_issue
676 journal = submit_email('ticket_reply.eml')
676 journal = submit_email('ticket_reply.eml')
677 assert journal.is_a?(Journal)
677 assert journal.is_a?(Journal)
678 assert_equal User.find_by_login('jsmith'), journal.user
678 assert_equal User.find_by_login('jsmith'), journal.user
679 assert_equal Issue.find(2), journal.journalized
679 assert_equal Issue.find(2), journal.journalized
680 assert_match /This is reply/, journal.notes
680 assert_match /This is reply/, journal.notes
681 assert_equal false, journal.private_notes
681 assert_equal false, journal.private_notes
682 assert_equal 'Feature request', journal.issue.tracker.name
682 assert_equal 'Feature request', journal.issue.tracker.name
683 end
683 end
684
684
685 def test_update_issue_should_accept_issue_id_after_space_inside_brackets
685 def test_update_issue_should_accept_issue_id_after_space_inside_brackets
686 journal = submit_email('ticket_reply_with_status.eml') do |email|
686 journal = submit_email('ticket_reply_with_status.eml') do |email|
687 assert email.sub!(/^Subject:.*$/, "Subject: Re: [Feature request #2] Add ingredients categories")
687 assert email.sub!(/^Subject:.*$/, "Subject: Re: [Feature request #2] Add ingredients categories")
688 end
688 end
689 assert journal.is_a?(Journal)
689 assert journal.is_a?(Journal)
690 assert_equal Issue.find(2), journal.journalized
690 assert_equal Issue.find(2), journal.journalized
691 end
691 end
692
692
693 def test_update_issue_should_accept_issue_id_inside_brackets
693 def test_update_issue_should_accept_issue_id_inside_brackets
694 journal = submit_email('ticket_reply_with_status.eml') do |email|
694 journal = submit_email('ticket_reply_with_status.eml') do |email|
695 assert email.sub!(/^Subject:.*$/, "Subject: Re: [#2] Add ingredients categories")
695 assert email.sub!(/^Subject:.*$/, "Subject: Re: [#2] Add ingredients categories")
696 end
696 end
697 assert journal.is_a?(Journal)
697 assert journal.is_a?(Journal)
698 assert_equal Issue.find(2), journal.journalized
698 assert_equal Issue.find(2), journal.journalized
699 end
699 end
700
700
701 def test_update_issue_should_ignore_bogus_issue_ids_in_subject
701 def test_update_issue_should_ignore_bogus_issue_ids_in_subject
702 journal = submit_email('ticket_reply_with_status.eml') do |email|
702 journal = submit_email('ticket_reply_with_status.eml') do |email|
703 assert email.sub!(/^Subject:.*$/, "Subject: Re: [12345#1][bogus#1][Feature request #2] Add ingredients categories")
703 assert email.sub!(/^Subject:.*$/, "Subject: Re: [12345#1][bogus#1][Feature request #2] Add ingredients categories")
704 end
704 end
705 assert journal.is_a?(Journal)
705 assert journal.is_a?(Journal)
706 assert_equal Issue.find(2), journal.journalized
706 assert_equal Issue.find(2), journal.journalized
707 end
707 end
708
708
709 def test_update_issue_with_attribute_changes
709 def test_update_issue_with_attribute_changes
710 # This email contains: 'Status: Resolved'
710 # This email contains: 'Status: Resolved'
711 journal = submit_email('ticket_reply_with_status.eml')
711 journal = submit_email('ticket_reply_with_status.eml')
712 assert journal.is_a?(Journal)
712 assert journal.is_a?(Journal)
713 issue = Issue.find(journal.issue.id)
713 issue = Issue.find(journal.issue.id)
714 assert_equal User.find_by_login('jsmith'), journal.user
714 assert_equal User.find_by_login('jsmith'), journal.user
715 assert_equal Issue.find(2), journal.journalized
715 assert_equal Issue.find(2), journal.journalized
716 assert_match /This is reply/, journal.notes
716 assert_match /This is reply/, journal.notes
717 assert_equal 'Feature request', journal.issue.tracker.name
717 assert_equal 'Feature request', journal.issue.tracker.name
718 assert_equal IssueStatus.find_by_name("Resolved"), issue.status
718 assert_equal IssueStatus.find_by_name("Resolved"), issue.status
719 assert_equal '2010-01-01', issue.start_date.to_s
719 assert_equal '2010-01-01', issue.start_date.to_s
720 assert_equal '2010-12-31', issue.due_date.to_s
720 assert_equal '2010-12-31', issue.due_date.to_s
721 assert_equal User.find_by_login('jsmith'), issue.assigned_to
721 assert_equal User.find_by_login('jsmith'), issue.assigned_to
722 assert_equal "52.6", issue.custom_value_for(CustomField.find_by_name('Float field')).value
722 assert_equal "52.6", issue.custom_value_for(CustomField.find_by_name('Float field')).value
723 # keywords should be removed from the email body
723 # keywords should be removed from the email body
724 assert !journal.notes.match(/^Status:/i)
724 assert !journal.notes.match(/^Status:/i)
725 assert !journal.notes.match(/^Start Date:/i)
725 assert !journal.notes.match(/^Start Date:/i)
726 end
726 end
727
727
728 def test_update_issue_with_attachment
728 def test_update_issue_with_attachment
729 assert_difference 'Journal.count' do
729 assert_difference 'Journal.count' do
730 assert_difference 'JournalDetail.count' do
730 assert_difference 'JournalDetail.count' do
731 assert_difference 'Attachment.count' do
731 assert_difference 'Attachment.count' do
732 assert_no_difference 'Issue.count' do
732 assert_no_difference 'Issue.count' do
733 journal = submit_email('ticket_with_attachment.eml') do |raw|
733 journal = submit_email('ticket_with_attachment.eml') do |raw|
734 raw.gsub! /^Subject: .*$/, 'Subject: Re: [Cookbook - Feature #2] (New) Add ingredients categories'
734 raw.gsub! /^Subject: .*$/, 'Subject: Re: [Cookbook - Feature #2] (New) Add ingredients categories'
735 end
735 end
736 end
736 end
737 end
737 end
738 end
738 end
739 end
739 end
740 journal = Journal.order('id DESC').first
740 journal = Journal.order('id DESC').first
741 assert_equal Issue.find(2), journal.journalized
741 assert_equal Issue.find(2), journal.journalized
742 assert_equal 1, journal.details.size
742 assert_equal 1, journal.details.size
743
743
744 detail = journal.details.first
744 detail = journal.details.first
745 assert_equal 'attachment', detail.property
745 assert_equal 'attachment', detail.property
746 assert_equal 'Paella.jpg', detail.value
746 assert_equal 'Paella.jpg', detail.value
747 end
747 end
748
748
749 def test_update_issue_should_send_email_notification
749 def test_update_issue_should_send_email_notification
750 ActionMailer::Base.deliveries.clear
750 ActionMailer::Base.deliveries.clear
751 journal = submit_email('ticket_reply.eml')
751 journal = submit_email('ticket_reply.eml')
752 assert journal.is_a?(Journal)
752 assert journal.is_a?(Journal)
753 assert_equal 1, ActionMailer::Base.deliveries.size
753 assert_equal 1, ActionMailer::Base.deliveries.size
754 end
754 end
755
755
756 def test_update_issue_should_not_set_defaults
756 def test_update_issue_should_not_set_defaults
757 journal = submit_email(
757 journal = submit_email(
758 'ticket_reply.eml',
758 'ticket_reply.eml',
759 :issue => {:tracker => 'Support request', :priority => 'High'}
759 :issue => {:tracker => 'Support request', :priority => 'High'}
760 )
760 )
761 assert journal.is_a?(Journal)
761 assert journal.is_a?(Journal)
762 assert_match /This is reply/, journal.notes
762 assert_match /This is reply/, journal.notes
763 assert_equal 'Feature request', journal.issue.tracker.name
763 assert_equal 'Feature request', journal.issue.tracker.name
764 assert_equal 'Normal', journal.issue.priority.name
764 assert_equal 'Normal', journal.issue.priority.name
765 end
765 end
766
766
767 def test_replying_to_a_private_note_should_add_reply_as_private
767 def test_replying_to_a_private_note_should_add_reply_as_private
768 private_journal = Journal.create!(:notes => 'Private notes', :journalized => Issue.find(1), :private_notes => true, :user_id => 2)
768 private_journal = Journal.create!(:notes => 'Private notes', :journalized => Issue.find(1), :private_notes => true, :user_id => 2)
769
769
770 assert_difference 'Journal.count' do
770 assert_difference 'Journal.count' do
771 journal = submit_email('ticket_reply.eml') do |email|
771 journal = submit_email('ticket_reply.eml') do |email|
772 email.sub! %r{^In-Reply-To:.*$}, "In-Reply-To: <redmine.journal-#{private_journal.id}.20060719210421@osiris>"
772 email.sub! %r{^In-Reply-To:.*$}, "In-Reply-To: <redmine.journal-#{private_journal.id}.20060719210421@osiris>"
773 end
773 end
774
774
775 assert_kind_of Journal, journal
775 assert_kind_of Journal, journal
776 assert_match /This is reply/, journal.notes
776 assert_match /This is reply/, journal.notes
777 assert_equal true, journal.private_notes
777 assert_equal true, journal.private_notes
778 end
778 end
779 end
779 end
780
780
781 def test_reply_to_a_message
781 def test_reply_to_a_message
782 m = submit_email('message_reply.eml')
782 m = submit_email('message_reply.eml')
783 assert m.is_a?(Message)
783 assert m.is_a?(Message)
784 assert !m.new_record?
784 assert !m.new_record?
785 m.reload
785 m.reload
786 assert_equal 'Reply via email', m.subject
786 assert_equal 'Reply via email', m.subject
787 # The email replies to message #2 which is part of the thread of message #1
787 # The email replies to message #2 which is part of the thread of message #1
788 assert_equal Message.find(1), m.parent
788 assert_equal Message.find(1), m.parent
789 end
789 end
790
790
791 def test_reply_to_a_message_by_subject
791 def test_reply_to_a_message_by_subject
792 m = submit_email('message_reply_by_subject.eml')
792 m = submit_email('message_reply_by_subject.eml')
793 assert m.is_a?(Message)
793 assert m.is_a?(Message)
794 assert !m.new_record?
794 assert !m.new_record?
795 m.reload
795 m.reload
796 assert_equal 'Reply to the first post', m.subject
796 assert_equal 'Reply to the first post', m.subject
797 assert_equal Message.find(1), m.parent
797 assert_equal Message.find(1), m.parent
798 end
798 end
799
799
800 def test_should_strip_tags_of_html_only_emails
800 def test_should_convert_tags_of_html_only_emails
801 issue = submit_email('ticket_html_only.eml', :issue => {:project => 'ecookbook'})
801 with_settings :text_formatting => 'textile' do
802 issue = submit_email('ticket_html_only.eml', :issue => {:project => 'ecookbook'})
803 assert issue.is_a?(Issue)
804 assert !issue.new_record?
805 issue.reload
806 assert_equal 'HTML email', issue.subject
807 assert_equal "This is a *html-only* email.\r\n\r\nh1. With a title\r\n\r\nand a paragraph.", issue.description
808 end
809 end
810
811 def test_should_handle_outlook_web_access_2010_html_only
812 issue = submit_email('outlook_web_access_2010_html_only.eml', :issue => {:project => 'ecookbook'})
813 assert issue.is_a?(Issue)
814 issue.reload
815 assert_equal 'Upgrade Redmine to 3.0.x', issue.subject
816 assert_equal "A mess.\r\n\r\n--Geoff Maciolek\r\nMYCOMPANYNAME, LLC", issue.description
817 end
818
819 def test_should_handle_outlook_2010_html_only
820 issue = submit_email('outlook_2010_html_only.eml', :issue => {:project => 'ecookbook'})
802 assert issue.is_a?(Issue)
821 assert issue.is_a?(Issue)
803 assert !issue.new_record?
804 issue.reload
822 issue.reload
805 assert_equal 'HTML email', issue.subject
823 assert_equal 'Test email', issue.subject
806 assert_equal 'This is a html-only email.', issue.description
824 assert_equal "Simple, unadorned test email generated by Outlook 2010. It is in HTML format, but" +
825 " no special formatting has been chosen. I’m going to save this as a draft and then manually" +
826 " drop it into the Inbox for scraping by Redmine 3.0.2.", issue.description
807 end
827 end
808
828
809 test "truncate emails with no setting should add the entire email into the issue" do
829 test "truncate emails with no setting should add the entire email into the issue" do
810 with_settings :mail_handler_body_delimiters => '' do
830 with_settings :mail_handler_body_delimiters => '' do
811 issue = submit_email('ticket_on_given_project.eml')
831 issue = submit_email('ticket_on_given_project.eml')
812 assert_issue_created(issue)
832 assert_issue_created(issue)
813 assert issue.description.include?('---')
833 assert issue.description.include?('---')
814 assert issue.description.include?('This paragraph is after the delimiter')
834 assert issue.description.include?('This paragraph is after the delimiter')
815 end
835 end
816 end
836 end
817
837
818 test "truncate emails with a single string should truncate the email at the delimiter for the issue" do
838 test "truncate emails with a single string should truncate the email at the delimiter for the issue" do
819 with_settings :mail_handler_body_delimiters => '---' do
839 with_settings :mail_handler_body_delimiters => '---' do
820 issue = submit_email('ticket_on_given_project.eml')
840 issue = submit_email('ticket_on_given_project.eml')
821 assert_issue_created(issue)
841 assert_issue_created(issue)
822 assert issue.description.include?('This paragraph is before delimiters')
842 assert issue.description.include?('This paragraph is before delimiters')
823 assert issue.description.include?('--- This line starts with a delimiter')
843 assert issue.description.include?('--- This line starts with a delimiter')
824 assert !issue.description.match(/^---$/)
844 assert !issue.description.match(/^---$/)
825 assert !issue.description.include?('This paragraph is after the delimiter')
845 assert !issue.description.include?('This paragraph is after the delimiter')
826 end
846 end
827 end
847 end
828
848
829 test "truncate emails with a single quoted reply should truncate the email at the delimiter with the quoted reply symbols (>)" do
849 test "truncate emails with a single quoted reply should truncate the email at the delimiter with the quoted reply symbols (>)" do
830 with_settings :mail_handler_body_delimiters => '--- Reply above. Do not remove this line. ---' do
850 with_settings :mail_handler_body_delimiters => '--- Reply above. Do not remove this line. ---' do
831 journal = submit_email('issue_update_with_quoted_reply_above.eml')
851 journal = submit_email('issue_update_with_quoted_reply_above.eml')
832 assert journal.is_a?(Journal)
852 assert journal.is_a?(Journal)
833 assert journal.notes.include?('An update to the issue by the sender.')
853 assert journal.notes.include?('An update to the issue by the sender.')
834 assert !journal.notes.match(Regexp.escape("--- Reply above. Do not remove this line. ---"))
854 assert !journal.notes.match(Regexp.escape("--- Reply above. Do not remove this line. ---"))
835 assert !journal.notes.include?('Looks like the JSON api for projects was missed.')
855 assert !journal.notes.include?('Looks like the JSON api for projects was missed.')
836 end
856 end
837 end
857 end
838
858
839 test "truncate emails with multiple quoted replies should truncate the email at the delimiter with the quoted reply symbols (>)" do
859 test "truncate emails with multiple quoted replies should truncate the email at the delimiter with the quoted reply symbols (>)" do
840 with_settings :mail_handler_body_delimiters => '--- Reply above. Do not remove this line. ---' do
860 with_settings :mail_handler_body_delimiters => '--- Reply above. Do not remove this line. ---' do
841 journal = submit_email('issue_update_with_multiple_quoted_reply_above.eml')
861 journal = submit_email('issue_update_with_multiple_quoted_reply_above.eml')
842 assert journal.is_a?(Journal)
862 assert journal.is_a?(Journal)
843 assert journal.notes.include?('An update to the issue by the sender.')
863 assert journal.notes.include?('An update to the issue by the sender.')
844 assert !journal.notes.match(Regexp.escape("--- Reply above. Do not remove this line. ---"))
864 assert !journal.notes.match(Regexp.escape("--- Reply above. Do not remove this line. ---"))
845 assert !journal.notes.include?('Looks like the JSON api for projects was missed.')
865 assert !journal.notes.include?('Looks like the JSON api for projects was missed.')
846 end
866 end
847 end
867 end
848
868
849 test "truncate emails with multiple strings should truncate the email at the first delimiter found (BREAK)" do
869 test "truncate emails with multiple strings should truncate the email at the first delimiter found (BREAK)" do
850 with_settings :mail_handler_body_delimiters => "---\nBREAK" do
870 with_settings :mail_handler_body_delimiters => "---\nBREAK" do
851 issue = submit_email('ticket_on_given_project.eml')
871 issue = submit_email('ticket_on_given_project.eml')
852 assert_issue_created(issue)
872 assert_issue_created(issue)
853 assert issue.description.include?('This paragraph is before delimiters')
873 assert issue.description.include?('This paragraph is before delimiters')
854 assert !issue.description.include?('BREAK')
874 assert !issue.description.include?('BREAK')
855 assert !issue.description.include?('This paragraph is between delimiters')
875 assert !issue.description.include?('This paragraph is between delimiters')
856 assert !issue.description.match(/^---$/)
876 assert !issue.description.match(/^---$/)
857 assert !issue.description.include?('This paragraph is after the delimiter')
877 assert !issue.description.include?('This paragraph is after the delimiter')
858 end
878 end
859 end
879 end
860
880
861 def test_attachments_that_match_mail_handler_excluded_filenames_should_be_ignored
881 def test_attachments_that_match_mail_handler_excluded_filenames_should_be_ignored
862 with_settings :mail_handler_excluded_filenames => '*.vcf, *.jpg' do
882 with_settings :mail_handler_excluded_filenames => '*.vcf, *.jpg' do
863 issue = submit_email('ticket_with_attachment.eml', :issue => {:project => 'onlinestore'})
883 issue = submit_email('ticket_with_attachment.eml', :issue => {:project => 'onlinestore'})
864 assert issue.is_a?(Issue)
884 assert issue.is_a?(Issue)
865 assert !issue.new_record?
885 assert !issue.new_record?
866 assert_equal 0, issue.reload.attachments.size
886 assert_equal 0, issue.reload.attachments.size
867 end
887 end
868 end
888 end
869
889
870 def test_attachments_that_do_not_match_mail_handler_excluded_filenames_should_be_attached
890 def test_attachments_that_do_not_match_mail_handler_excluded_filenames_should_be_attached
871 with_settings :mail_handler_excluded_filenames => '*.vcf, *.gif' do
891 with_settings :mail_handler_excluded_filenames => '*.vcf, *.gif' do
872 issue = submit_email('ticket_with_attachment.eml', :issue => {:project => 'onlinestore'})
892 issue = submit_email('ticket_with_attachment.eml', :issue => {:project => 'onlinestore'})
873 assert issue.is_a?(Issue)
893 assert issue.is_a?(Issue)
874 assert !issue.new_record?
894 assert !issue.new_record?
875 assert_equal 1, issue.reload.attachments.size
895 assert_equal 1, issue.reload.attachments.size
876 end
896 end
877 end
897 end
878
898
879 def test_email_with_long_subject_line
899 def test_email_with_long_subject_line
880 issue = submit_email('ticket_with_long_subject.eml')
900 issue = submit_email('ticket_with_long_subject.eml')
881 assert issue.is_a?(Issue)
901 assert issue.is_a?(Issue)
882 assert_equal issue.subject, 'New ticket on a given project with a very long subject line which exceeds 255 chars and should not be ignored but chopped off. And if the subject line is still not long enough, we just add more text. And more text. Wow, this is really annoying. Especially, if you have nothing to say...'[0,255]
902 assert_equal issue.subject, 'New ticket on a given project with a very long subject line which exceeds 255 chars and should not be ignored but chopped off. And if the subject line is still not long enough, we just add more text. And more text. Wow, this is really annoying. Especially, if you have nothing to say...'[0,255]
883 end
903 end
884
904
885 def test_first_keyword_should_be_matched
905 def test_first_keyword_should_be_matched
886 issue = submit_email('ticket_with_duplicate_keyword.eml', :allow_override => 'priority')
906 issue = submit_email('ticket_with_duplicate_keyword.eml', :allow_override => 'priority')
887 assert issue.is_a?(Issue)
907 assert issue.is_a?(Issue)
888 assert_equal 'High', issue.priority.name
908 assert_equal 'High', issue.priority.name
889 end
909 end
890
910
891 def test_keyword_after_delimiter_should_be_ignored
911 def test_keyword_after_delimiter_should_be_ignored
892 with_settings :mail_handler_body_delimiters => "== DELIMITER ==" do
912 with_settings :mail_handler_body_delimiters => "== DELIMITER ==" do
893 issue = submit_email('ticket_with_keyword_after_delimiter.eml', :allow_override => 'priority')
913 issue = submit_email('ticket_with_keyword_after_delimiter.eml', :allow_override => 'priority')
894 assert issue.is_a?(Issue)
914 assert issue.is_a?(Issue)
895 assert_equal 'Normal', issue.priority.name
915 assert_equal 'Normal', issue.priority.name
896 end
916 end
897 end
917 end
898
918
899 def test_new_user_from_attributes_should_return_valid_user
919 def test_new_user_from_attributes_should_return_valid_user
900 to_test = {
920 to_test = {
901 # [address, name] => [login, firstname, lastname]
921 # [address, name] => [login, firstname, lastname]
902 ['jsmith@example.net', nil] => ['jsmith@example.net', 'jsmith', '-'],
922 ['jsmith@example.net', nil] => ['jsmith@example.net', 'jsmith', '-'],
903 ['jsmith@example.net', 'John'] => ['jsmith@example.net', 'John', '-'],
923 ['jsmith@example.net', 'John'] => ['jsmith@example.net', 'John', '-'],
904 ['jsmith@example.net', 'John Smith'] => ['jsmith@example.net', 'John', 'Smith'],
924 ['jsmith@example.net', 'John Smith'] => ['jsmith@example.net', 'John', 'Smith'],
905 ['jsmith@example.net', 'John Paul Smith'] => ['jsmith@example.net', 'John', 'Paul Smith'],
925 ['jsmith@example.net', 'John Paul Smith'] => ['jsmith@example.net', 'John', 'Paul Smith'],
906 ['jsmith@example.net', 'AVeryLongFirstnameThatExceedsTheMaximumLength Smith'] => ['jsmith@example.net', 'AVeryLongFirstnameThatExceedsT', 'Smith'],
926 ['jsmith@example.net', 'AVeryLongFirstnameThatExceedsTheMaximumLength Smith'] => ['jsmith@example.net', 'AVeryLongFirstnameThatExceedsT', 'Smith'],
907 ['jsmith@example.net', 'John AVeryLongLastnameThatExceedsTheMaximumLength'] => ['jsmith@example.net', 'John', 'AVeryLongLastnameThatExceedsTh']
927 ['jsmith@example.net', 'John AVeryLongLastnameThatExceedsTheMaximumLength'] => ['jsmith@example.net', 'John', 'AVeryLongLastnameThatExceedsTh']
908 }
928 }
909
929
910 to_test.each do |attrs, expected|
930 to_test.each do |attrs, expected|
911 user = MailHandler.new_user_from_attributes(attrs.first, attrs.last)
931 user = MailHandler.new_user_from_attributes(attrs.first, attrs.last)
912
932
913 assert user.valid?, user.errors.full_messages.to_s
933 assert user.valid?, user.errors.full_messages.to_s
914 assert_equal attrs.first, user.mail
934 assert_equal attrs.first, user.mail
915 assert_equal expected[0], user.login
935 assert_equal expected[0], user.login
916 assert_equal expected[1], user.firstname
936 assert_equal expected[1], user.firstname
917 assert_equal expected[2], user.lastname
937 assert_equal expected[2], user.lastname
918 assert_equal 'only_my_events', user.mail_notification
938 assert_equal 'only_my_events', user.mail_notification
919 end
939 end
920 end
940 end
921
941
922 def test_new_user_from_attributes_should_use_default_login_if_invalid
942 def test_new_user_from_attributes_should_use_default_login_if_invalid
923 user = MailHandler.new_user_from_attributes('foo+bar@example.net')
943 user = MailHandler.new_user_from_attributes('foo+bar@example.net')
924 assert user.valid?
944 assert user.valid?
925 assert user.login =~ /^user[a-f0-9]+$/
945 assert user.login =~ /^user[a-f0-9]+$/
926 assert_equal 'foo+bar@example.net', user.mail
946 assert_equal 'foo+bar@example.net', user.mail
927 end
947 end
928
948
929 def test_new_user_with_utf8_encoded_fullname_should_be_decoded
949 def test_new_user_with_utf8_encoded_fullname_should_be_decoded
930 assert_difference 'User.count' do
950 assert_difference 'User.count' do
931 issue = submit_email(
951 issue = submit_email(
932 'fullname_of_sender_as_utf8_encoded.eml',
952 'fullname_of_sender_as_utf8_encoded.eml',
933 :issue => {:project => 'ecookbook'},
953 :issue => {:project => 'ecookbook'},
934 :unknown_user => 'create'
954 :unknown_user => 'create'
935 )
955 )
936 end
956 end
937 user = User.order('id DESC').first
957 user = User.order('id DESC').first
938 assert_equal "foo@example.org", user.mail
958 assert_equal "foo@example.org", user.mail
939 str1 = "\xc3\x84\xc3\xa4".force_encoding('UTF-8')
959 str1 = "\xc3\x84\xc3\xa4".force_encoding('UTF-8')
940 str2 = "\xc3\x96\xc3\xb6".force_encoding('UTF-8')
960 str2 = "\xc3\x96\xc3\xb6".force_encoding('UTF-8')
941 assert_equal str1, user.firstname
961 assert_equal str1, user.firstname
942 assert_equal str2, user.lastname
962 assert_equal str2, user.lastname
943 end
963 end
944
964
945 def test_extract_options_from_env_should_return_options
965 def test_extract_options_from_env_should_return_options
946 options = MailHandler.extract_options_from_env({
966 options = MailHandler.extract_options_from_env({
947 'tracker' => 'defect',
967 'tracker' => 'defect',
948 'project' => 'foo',
968 'project' => 'foo',
949 'unknown_user' => 'create'
969 'unknown_user' => 'create'
950 })
970 })
951
971
952 assert_equal({
972 assert_equal({
953 :issue => {:tracker => 'defect', :project => 'foo'},
973 :issue => {:tracker => 'defect', :project => 'foo'},
954 :unknown_user => 'create'
974 :unknown_user => 'create'
955 }, options)
975 }, options)
956 end
976 end
957
977
958 def test_safe_receive_should_rescue_exceptions_and_return_false
978 def test_safe_receive_should_rescue_exceptions_and_return_false
959 MailHandler.stubs(:receive).raises(Exception.new "Something went wrong")
979 MailHandler.stubs(:receive).raises(Exception.new "Something went wrong")
960
980
961 assert_equal false, MailHandler.safe_receive
981 assert_equal false, MailHandler.safe_receive
962 end
982 end
963
983
964 private
984 private
965
985
966 def submit_email(filename, options={})
986 def submit_email(filename, options={})
967 raw = IO.read(File.join(FIXTURES_PATH, filename))
987 raw = IO.read(File.join(FIXTURES_PATH, filename))
968 yield raw if block_given?
988 yield raw if block_given?
969 MailHandler.receive(raw, options)
989 MailHandler.receive(raw, options)
970 end
990 end
971
991
972 def assert_issue_created(issue)
992 def assert_issue_created(issue)
973 assert issue.is_a?(Issue)
993 assert issue.is_a?(Issue)
974 assert !issue.new_record?
994 assert !issue.new_record?
975 issue.reload
995 issue.reload
976 end
996 end
977 end
997 end
General Comments 0
You need to be logged in to leave comments. Login now