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